1package protocol
2
3import (
4 "errors"
5 "fmt"
6 "unicode"
7 "unicode/utf8"
8
9 "github.com/bnema/vev/internal/domain"
10)
11
12// ErrInvalidRouteWire preserves the public error classification used by
13// strict route codecs while route values live in the semantic protocol.
14var ErrInvalidRouteWire = errors.New("invalid route wire message")
15
16// RouteKind identifies the kind of target represented by one display entry.
17type RouteKind uint8
18
19const (
20 RouteKindLocal RouteKind = iota + 1
21 RouteKindRemote
22)
23
24func (k RouteKind) valid() bool {
25 switch k {
26 case RouteKindLocal, RouteKindRemote:
27 return true
28 default:
29 return false
30 }
31}
32
33func (k RouteKind) Validate() error {
34 if !k.valid() {
35 return errors.New("invalid route kind")
36 }
37 return nil
38}
39
40// RouteReachability is a closed display hint. It is never used as proof that
41// a route can be attached; the exact target and daemon still decide that.
42type RouteReachability uint8
43
44const (
45 RouteReachabilityUnknown RouteReachability = iota
46 RouteReachabilityReachable
47 RouteReachabilityUnavailable
48)
49
50func (r RouteReachability) valid() bool {
51 switch r {
52 case RouteReachabilityUnknown, RouteReachabilityReachable, RouteReachabilityUnavailable:
53 return true
54 default:
55 return false
56 }
57}
58
59func (r RouteReachability) Validate() error {
60 if !r.valid() {
61 return errors.New("invalid route reachability")
62 }
63 return nil
64}
65
66// ValidateRouteLabel rejects malformed or terminal-control text before it can
67// enter a daemon-rendered route snapshot. Empty labels are allowed only when
68// the caller explicitly marks them optional.
69func ValidateRouteLabel(value string, allowEmpty bool) error {
70 if !allowEmpty && value == "" {
71 return errors.New("route label is empty")
72 }
73 if len(value) > RouteLabelMaxBytes {
74 return errors.New("route label is too long")
75 }
76 if !utf8.ValidString(value) {
77 return errors.New("route label is not valid UTF-8")
78 }
79 for _, r := range value {
80 if unicode.IsControl(r) {
81 return errors.New("route label contains a control character")
82 }
83 if unicode.Is(unicode.Bidi_Control, r) || r == '\u2028' || r == '\u2029' {
84 return errors.New("route label contains a bidirectional or line-separator control character")
85 }
86 }
87 return nil
88}
89
90const NoTabIndex int32 = -1
91
92// SessionAttachTarget is the daemon-local, transport-neutral identity used by
93// session handshakes and handoffs. Endpoint, display origin, and registration
94// authority deliberately remain outside this contract.
95type SessionAttachTarget struct {
96 SessionID domain.SessionID
97 LifecycleID domain.SessionLifecycleID
98 SessionName string
99 TabID domain.TabStableID
100 TabIndex int32
101 TabRawName string
102 TabExpectedCount uint16
103 Stopped bool
104}
105
106func (t SessionAttachTarget) selector() domain.TabSelector {
107 if t.TabID != "" {
108 return domain.NewStableTabSelector(t.TabID)
109 }
110 if t.TabIndex != NoTabIndex {
111 return domain.NewOrdinalTabSelector(uint16(t.TabIndex), t.TabRawName, t.TabExpectedCount)
112 }
113 return domain.TabSelector{}
114}
115
116func (t SessionAttachTarget) ResolveTab(tabs []domain.TabSelectorTab) (int, bool) {
117 selector := t.selector()
118 if selector == (domain.TabSelector{}) {
119 return 0, len(tabs) <= 1
120 }
121 return selector.Resolve(tabs)
122}
123
124func (t SessionAttachTarget) Validate() error {
125 if t.LifecycleID == (domain.SessionLifecycleID{}) {
126 return errors.New("missing session lifecycle")
127 }
128 if err := domain.ValidateSessionName(t.SessionName); err != nil {
129 return fmt.Errorf("invalid session name: %w", err)
130 }
131 if t.Stopped && t.SessionID != "" {
132 return errors.New("stopped target carries a session ID")
133 }
134 switch {
135 case t.TabID != "":
136 if domain.ValidateTabStableID(t.TabID) != nil || t.TabIndex != NoTabIndex || t.TabRawName != "" || t.TabExpectedCount != 0 {
137 return errors.New("invalid stable tab selector")
138 }
139 case t.TabIndex != NoTabIndex:
140 if t.TabIndex < 0 {
141 return errors.New("invalid ordinal tab selector")
142 }
143 if err := domain.NewOrdinalTabSelector(uint16(t.TabIndex), t.TabRawName, t.TabExpectedCount).Validate(); err != nil {
144 return fmt.Errorf("invalid ordinal tab selector: %w", err)
145 }
146 case t.TabRawName != "" || t.TabExpectedCount != 0:
147 return errors.New("incomplete tab selector")
148 }
149 if !t.Stopped && t.TabID == "" {
150 return errors.New("live target requires a stable tab selector")
151 }
152 return nil
153}
154
155// SessionAttachTargetFromRemote strips routing and presentation data from a
156// picker target before it enters the daemon session contract.
157func SessionAttachTargetFromRemote(remote domain.RemoteSessionTarget) SessionAttachTarget {
158 t := SessionAttachTarget{LifecycleID: remote.LifecycleID, SessionName: remote.SessionName, TabIndex: NoTabIndex, Stopped: remote.Stopped}
159 if !remote.Stopped {
160 t.TabID = remote.LiveTabID
161 return t
162 }
163 switch remote.StoppedTab.Kind {
164 case domain.TabSelectorByStableID:
165 t.TabID = remote.StoppedTab.StableID
166 case domain.TabSelectorByOrdinal:
167 t.TabIndex = int32(remote.StoppedTab.Ordinal)
168 t.TabRawName = remote.StoppedTab.RawName
169 t.TabExpectedCount = remote.StoppedTab.ExpectedCount
170 }
171 return t
172}
173
174// ExactSessionTarget is the daemon-neutral identity required to attach to a
175// specific session lifecycle. The transport endpoint is deliberately absent:
176// the selected route's dialer owns that boundary.
177type ExactSessionTarget struct {
178 LifecycleID domain.SessionLifecycleID
179 SessionName string
180}
181
182func (t ExactSessionTarget) Validate() error {
183 if t.LifecycleID == (domain.SessionLifecycleID{}) {
184 return errors.New("missing session lifecycle")
185 }
186 if err := domain.ValidateSessionName(t.SessionName); err != nil {
187 return fmt.Errorf("invalid session name: %w", err)
188 }
189 return nil
190}
191
192// CommittedRouteIdentity is the identity a daemon commits after accepting an
193// attach. It is separate from display data so a stale presentation cannot
194// become an attach authority.
195type CommittedRouteIdentity struct {
196 Target ExactSessionTarget
197 Ephemeral bool
198}
199
200func (i CommittedRouteIdentity) Validate() error {
201 if err := i.Target.Validate(); err != nil {
202 return err
203 }
204 return nil
205}
206
207// RoutePosition is mutable per-client route state published by the daemon.
208// Target binds the tab cursor to one exact session lifecycle so delayed frames
209// cannot update another route.
210type RoutePosition struct {
211 Target ExactSessionTarget
212 ActiveTabID domain.TabStableID
213}
214
215func (p RoutePosition) Validate() error {
216 if err := p.Target.Validate(); err != nil {
217 return err
218 }
219 if err := domain.ValidateTabStableID(p.ActiveTabID); err != nil {
220 return fmt.Errorf("invalid active tab ID: %w", err)
221 }
222 return nil
223}
224
225// RouteRef is an opaque client-ledger reference. It is intentionally not a
226// lifecycle identifier and is meaningful only with the matching generation.
227type RouteRef struct {
228 Key uint64
229 Generation uint64
230}
231
232// RouteAttentionTarget binds one displayed route reference to an exact
233// lifecycle on the serving daemon or a monitored inventory source. It carries
234// no dialer, credential, or endpoint.
235type RouteAttentionTarget struct {
236 Ref RouteRef
237 Target ExactSessionTarget
238 // SourceKey is empty for the serving daemon, or an opaque monitored
239 // inventory source. It never carries an endpoint or credential.
240 SourceKey string
241}
242
243// RouteRetired invalidates one subscribed exact lifecycle, not its name.
244// The reference fences delayed notifications against replacement routes.
245type RouteRetired struct {
246 Ref RouteRef
247 Target ExactSessionTarget
248}
249
250// Validate rejects a retirement without the nonzero reference that fences it
251// and without a valid lifecycle target. Both encode and decode call it.
252func (r RouteRetired) Validate() error {
253 if r.Ref.IsZero() || r.Ref.Validate() != nil {
254 return fmt.Errorf("%w: retired route reference is zero", ErrInvalidRouteWire)
255 }
256 if err := r.Target.Validate(); err != nil {
257 return fmt.Errorf("%w: invalid retired route target: %v", ErrInvalidRouteWire, err)
258 }
259 return nil
260}
261
262// RouteAttentionSubscription is the bounded, client-owned mapping a daemon
263// uses to resolve live attention and retire authoritatively absent lifecycles.
264type RouteAttentionSubscription struct {
265 Targets []RouteAttentionTarget
266}
267
268// Validate enforces the bounded, duplicate-free attention mapping invariants
269// on both the encode and decode sides of the wire conversion.
270func (r RouteAttentionSubscription) Validate() error {
271 if len(r.Targets) > RouteSnapshotMaxEntries {
272 return fmt.Errorf("%w: too many attention targets", ErrInvalidRouteWire)
273 }
274 refs := make(map[RouteRef]struct{}, len(r.Targets))
275 for _, target := range r.Targets {
276 if err := ValidateRouteLabel(target.SourceKey, true); err != nil {
277 return fmt.Errorf("%w: invalid route source", ErrInvalidRouteWire)
278 }
279 if err := target.Ref.Validate(); err != nil || target.Ref.IsZero() {
280 return fmt.Errorf("%w: invalid attention route reference", ErrInvalidRouteWire)
281 }
282 if err := target.Target.Validate(); err != nil {
283 return fmt.Errorf("%w: invalid attention target: %v", ErrInvalidRouteWire, err)
284 }
285 if _, exists := refs[target.Ref]; exists {
286 return fmt.Errorf("%w: duplicate attention route reference", ErrInvalidRouteWire)
287 }
288 refs[target.Ref] = struct{}{}
289 }
290 return nil
291}
292
293func (r RouteRef) IsZero() bool { return r.Key == 0 && r.Generation == 0 }
294
295func (r RouteRef) Validate() error {
296 if r.IsZero() {
297 return nil
298 }
299 if r.Key == 0 || r.Generation == 0 {
300 return errors.New("route reference must contain key and generation")
301 }
302 return nil
303}
304
305// RecentRouteEntry is the complete daemon-neutral identity and display view of
306// one route. Target carries only the committed lifecycle UUID and name; it
307// contains no dialer, credential, endpoint, or live session pointer.
308type RecentRouteEntry struct {
309 Key uint64
310 Generation uint64
311 Target ExactSessionTarget
312 Name string
313 HostLabel string
314 Kind RouteKind
315 Ephemeral bool
316 Attention bool
317 Reachability RouteReachability
318 // AttentionSeq orders attention onsets across every daemon the client
319 // observes: a smaller value is older. It is zero without attention.
320 AttentionSeq uint64
321 // Visited reports that this client attached to the route. The status-bar
322 // history and jump-recent ranks show visited routes plus ringing ones.
323 Visited bool
324}
325
326// RouteHost is one daemon the client can create a session on, other than
327// the serving daemon. Its reference shares the entry namespace; it carries
328// no endpoint, dialer, or credential.
329type RouteHost struct {
330 Key uint64
331 Generation uint64
332 Label string
333 Kind RouteKind
334}
335
336// RecentRouteSnapshot is an immutable, bounded publication of the client
337// route ledger. Entries are ordered from most recent to least recent.
338type RecentRouteSnapshot struct {
339 Generation uint64
340 Active RouteRef
341 // ActiveEntry maps Active's committed lifecycle UUID to its presentation.
342 // It is not a navigation candidate and therefore remains separate from
343 // Entries, which are ordered from most recent to least recent.
344 ActiveEntry RecentRouteEntry
345 Previous RouteRef
346 Home RouteRef
347 Entries []RecentRouteEntry
348 // Hosts are the creation destinations besides the serving daemon.
349 Hosts []RouteHost
350}
351
352// validateRecentRouteEntry enforces one entry's identity and display bounds.
353func validateRecentRouteEntry(entry RecentRouteEntry) error {
354 if entry.Key == 0 || entry.Generation == 0 {
355 return fmt.Errorf("%w: route entry identity is zero", ErrInvalidRouteWire)
356 }
357 if err := entry.Target.Validate(); err != nil {
358 return fmt.Errorf("%w: invalid route lifecycle target: %v", ErrInvalidRouteWire, err)
359 }
360 if entry.Name != entry.Target.SessionName {
361 return fmt.Errorf("%w: route name does not match lifecycle target", ErrInvalidRouteWire)
362 }
363 if err := ValidateRouteLabel(entry.Name, false); err != nil {
364 return fmt.Errorf("%w: route name: %v", ErrInvalidRouteWire, err)
365 }
366 if err := ValidateRouteLabel(entry.HostLabel, true); err != nil {
367 return fmt.Errorf("%w: route host label: %v", ErrInvalidRouteWire, err)
368 }
369 if entry.Kind.Validate() != nil {
370 return fmt.Errorf("%w: invalid route kind", ErrInvalidRouteWire)
371 }
372 if entry.Reachability.Validate() != nil {
373 return fmt.Errorf("%w: invalid route reachability", ErrInvalidRouteWire)
374 }
375 if entry.AttentionSeq != 0 && !entry.Attention {
376 return fmt.Errorf("%w: attention order without attention", ErrInvalidRouteWire)
377 }
378 return nil
379}
380
381func validateRouteHost(host RouteHost) error {
382 if host.Key == 0 || host.Generation == 0 {
383 return fmt.Errorf("%w: route host identity is zero", ErrInvalidRouteWire)
384 }
385 if err := ValidateRouteLabel(host.Label, false); err != nil {
386 return fmt.Errorf("%w: route host label: %v", ErrInvalidRouteWire, err)
387 }
388 if host.Kind.Validate() != nil {
389 return fmt.Errorf("%w: invalid route host kind", ErrInvalidRouteWire)
390 }
391 return nil
392}
393
394// Validate enforces the bounded, self-consistent invariants of one complete
395// route publication: entry budget, nonzero generation for non-empty
396// snapshots, matching active presentation, unique entry references, and
397// previous/home references that resolve inside the snapshot. Both encode and
398// decode call it so a malformed snapshot cannot be produced or accepted.
399func (s RecentRouteSnapshot) Validate() error {
400 if len(s.Entries) > RouteSnapshotMaxEntries {
401 return fmt.Errorf("%w: too many route entries", ErrInvalidRouteWire)
402 }
403 if len(s.Hosts) > RouteSnapshotMaxHosts {
404 return fmt.Errorf("%w: too many route hosts", ErrInvalidRouteWire)
405 }
406 if s.Generation == 0 && (len(s.Entries) != 0 || len(s.Hosts) != 0 || s.ActiveEntry != (RecentRouteEntry{})) {
407 return fmt.Errorf("%w: non-empty snapshot has zero generation", ErrInvalidRouteWire)
408 }
409 for _, ref := range []RouteRef{s.Active, s.Previous, s.Home} {
410 if err := ref.Validate(); err != nil {
411 return fmt.Errorf("%w: route reference: %v", ErrInvalidRouteWire, err)
412 }
413 }
414 if s.Active.IsZero() {
415 if s.ActiveEntry != (RecentRouteEntry{}) {
416 return fmt.Errorf("%w: active presentation has no active route", ErrInvalidRouteWire)
417 }
418 } else {
419 if err := validateRecentRouteEntry(s.ActiveEntry); err != nil {
420 return err
421 }
422 if s.Active != (RouteRef{Key: s.ActiveEntry.Key, Generation: s.ActiveEntry.Generation}) {
423 return fmt.Errorf("%w: active presentation does not match active route", ErrInvalidRouteWire)
424 }
425 }
426 if !s.Active.IsZero() && s.Active == s.Previous {
427 return fmt.Errorf("%w: active and previous routes are identical", ErrInvalidRouteWire)
428 }
429 refs := make(map[RouteRef]struct{}, len(s.Entries))
430 for _, entry := range s.Entries {
431 if s.Active == (RouteRef{Key: entry.Key, Generation: entry.Generation}) {
432 return fmt.Errorf("%w: active route must be metadata-only", ErrInvalidRouteWire)
433 }
434 if err := validateRecentRouteEntry(entry); err != nil {
435 return err
436 }
437 ref := RouteRef{Key: entry.Key, Generation: entry.Generation}
438 if _, exists := refs[ref]; exists {
439 return fmt.Errorf("%w: duplicate route entry", ErrInvalidRouteWire)
440 }
441 refs[ref] = struct{}{}
442 }
443 for _, host := range s.Hosts {
444 if err := validateRouteHost(host); err != nil {
445 return err
446 }
447 ref := RouteRef{Key: host.Key, Generation: host.Generation}
448 if _, exists := refs[ref]; exists || ref == s.Active {
449 return fmt.Errorf("%w: duplicate route host", ErrInvalidRouteWire)
450 }
451 refs[ref] = struct{}{}
452 }
453 for _, item := range []struct {
454 name string
455 ref RouteRef
456 }{
457 {name: "previous", ref: s.Previous},
458 {name: "home", ref: s.Home},
459 } {
460 if item.ref.IsZero() || item.ref == s.Active {
461 // Active is metadata-only and intentionally excluded from the
462 // recent display entries. Home may point at active as well.
463 continue
464 }
465 if _, exists := refs[item.ref]; !exists {
466 return fmt.Errorf("%w: %s route reference is absent from snapshot", ErrInvalidRouteWire, item.name)
467 }
468 }
469 return nil
470}
471
472// RouteNavigationAction asks the client to resolve one entry from a specific
473// complete snapshot. It carries no session name, endpoint, or credential.
474type RouteNavigationAction struct {
475 CauseActionID uint64
476 SnapshotGeneration uint64
477 Key uint64
478 Generation uint64
479}
480
481// Validate rejects a navigation action whose snapshot/entry identity is zero;
482// both encode and decode call it so a stale or malformed action never ships.
483func (a RouteNavigationAction) Validate() error {
484 if a.SnapshotGeneration == 0 || a.Key == 0 || a.Generation == 0 {
485 return fmt.Errorf("%w: navigation action identity is zero", ErrInvalidRouteWire)
486 }
487 return nil
488}
489
490// RouteCreateSessionAction asks the client to create a named session through
491// one exact route authority from the latest complete snapshot.
492type RouteCreateSessionAction struct {
493 CauseActionID uint64
494 RequestID uint64
495 SnapshotGeneration uint64
496 Key uint64
497 Generation uint64
498 SessionName string
499}
500
501func (a RouteCreateSessionAction) Validate() error {
502 if a.RequestID == 0 || a.SnapshotGeneration == 0 || a.Key == 0 || a.Generation == 0 || domain.ValidateSessionName(a.SessionName) != nil {
503 return ErrInvalidRouteWire
504 }
505 return nil
506}
507
508// SamePeerSwitchRequest confirms a daemon-offered endpoint-empty target. It
509// carries only the exact lifecycle identity and the client-owned tab cursor;
510// transport origin remains proven by the existing authenticated connection.
511type SamePeerSwitchRequest struct {
512 RequestID uint64
513 Target ExactSessionTarget
514 PreferredTabID domain.TabStableID
515}
516
517func (r SamePeerSwitchRequest) Validate() error {
518 if r.RequestID == 0 {
519 return ErrInvalidRouteWire
520 }
521 if err := r.Target.Validate(); err != nil {
522 return ErrInvalidRouteWire
523 }
524 if r.PreferredTabID != "" {
525 if err := domain.ValidateTabStableID(r.PreferredTabID); err != nil {
526 return ErrInvalidRouteWire
527 }
528 }
529 return nil
530}
531
532// SamePeerSwitchFailureCode is a closed pre-commit rejection taxonomy.
533type SamePeerSwitchFailureCode uint8
534
535const (
536 SamePeerSwitchStaleTarget SamePeerSwitchFailureCode = iota + 1
537 SamePeerSwitchUnavailable
538)
539
540func (c SamePeerSwitchFailureCode) valid() bool {
541 return c == SamePeerSwitchStaleTarget || c == SamePeerSwitchUnavailable
542}
543
544func (c SamePeerSwitchFailureCode) Validate() error {
545 if !c.valid() {
546 return ErrInvalidRouteWire
547 }
548 return nil
549}
550
551// SamePeerSwitchFailure leaves the source attachment unchanged. RequestID
552// rejects a delayed failure after a later user selection.
553type SamePeerSwitchFailure struct {
554 RequestID uint64
555 Code SamePeerSwitchFailureCode
556}
557
558func (f SamePeerSwitchFailure) Validate() error {
559 if f.RequestID == 0 || !f.Code.valid() {
560 return ErrInvalidRouteWire
561 }
562 return nil
563}
564
565// RouteNavigationFailure is a bounded taxonomy for a rejected or stale route
566// action. User-facing text remains local to the receiving client.
567type RouteNavigationFailure struct {
568 Key uint64
569 Generation uint64
570 Code RouteFailureCode
571}
572
573type RouteFailureCode uint8
574
575const (
576 RouteFailureStaleSelection RouteFailureCode = iota + 1
577 RouteFailureNoSuchRoute
578 RouteFailureUnavailable
579 RouteFailureTargetChanged
580 RouteFailureOriginUnavailable
581)
582
583func (c RouteFailureCode) valid() bool {
584 switch c {
585 case RouteFailureStaleSelection, RouteFailureNoSuchRoute, RouteFailureUnavailable, RouteFailureTargetChanged, RouteFailureOriginUnavailable:
586 return true
587 default:
588 return false
589 }
590}
591
592func (c RouteFailureCode) Validate() error {
593 if !c.valid() {
594 return ErrInvalidRouteWire
595 }
596 return nil
597}
598
599// Validate requires the nonzero entry identity that correlates the failure
600// with its navigation action, plus a bounded failure code. Both encode and
601// decode call it so a zero identity never ships.
602func (f RouteNavigationFailure) Validate() error {
603 if f.Key == 0 || f.Generation == 0 {
604 return fmt.Errorf("%w: failure identity is zero", ErrInvalidRouteWire)
605 }
606 return f.Code.Validate()
607}
608
609// SessionCreationFailure reports a correlated pre-commit create transition
610// failure after the client has restored the source route.
611type SessionCreationFailure struct {
612 RequestID uint64
613 Code RouteFailureCode
614}
615
616func (f SessionCreationFailure) Validate() error {
617 if f.RequestID == 0 || !f.Code.valid() {
618 return ErrInvalidRouteWire
619 }
620 return nil
621}
622
623const (
624 // RouteSnapshotMaxEntries bounds one immutable publication and the amount
625 // of work a receiver performs before returning to its transport loop. The
626 // private client history may use a smaller product cap.
627 RouteSnapshotMaxEntries = 32
628 // RouteSnapshotMaxHosts bounds the creation destinations of one snapshot.
629 RouteSnapshotMaxHosts = 32
630 RouteLabelMaxBytes = 256
631)