detached 47e144b

feat(protocol): add parked route messages

commit
47e144b1c7518882ec86437858edd528681565cf
author
brice · · 2026-08-25 13:34 UTC
parent
f571e9b
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +292 -46
M internal/ports/frame.go +57 -3
@@ -12,7 +12,7 @@ import (
1212 const HandshakeTimeout = 15 * time.Second
1313
1414 // ProtocolVersion is the current vev IPC wire protocol version.
15-const ProtocolVersion uint16 = 33
15+const ProtocolVersion uint16 = 34
1616
1717 // MaxFrameLen is the largest permitted frame length, including the type byte
1818 // and excluding the four-byte length prefix.
@@ -22,8 +22,8 @@ const MaxFrameLen = 16 << 20
2222 type MsgType uint8
2323
2424 // Frame message types use non-contiguous allocations: the legacy client range
25-// is 1–13 and 15, and newer client controls use 32–33. Server-originated
26-// messages occupy 16–23, 25–31, and 34. Values 14 and 24 remain reserved for
25+// is 1–13 and 15, and newer client controls use 32–33 and 35. Server-originated
26+// messages occupy 16–23, 25–31, 34, and 36. Values 14 and 24 remain reserved for
2727 // future extensions.
2828 const (
2929 MsgHello MsgType = 1
@@ -42,6 +42,7 @@ const (
4242 MsgRemotePreviewRequest MsgType = 15
4343 MsgRouteAttentionSubscription MsgType = 32
4444 MsgSamePeerSwitchRequest MsgType = 33
45+ MsgParkedRouteRequest MsgType = 35
4546
4647 MsgWelcome MsgType = 16
4748 MsgError MsgType = 17
@@ -61,6 +62,7 @@ const (
6162 MsgRouteNavigationFailure MsgType = 30
6263 MsgRoutePosition MsgType = 31
6364 MsgSamePeerSwitchFailure MsgType = 34
65+ MsgParkedRouteResponse MsgType = 36
6466 )
6567
6668 // Frame is the unit of exchange over a Transport: a typed, length-delimited
@@ -97,6 +99,58 @@ const (
9799 NavigationBack NavigationAction = 2
98100 )
99101
102+// ParkedRouteLeaseID is an opaque capability minted by the attached daemon for
103+// one home-picker excursion. It is bound server-side to the exact connection
104+// generation and transport that received the navigation directive.
105+type ParkedRouteLeaseID [16]byte
106+
107+func (id ParkedRouteLeaseID) IsZero() bool { return id == ParkedRouteLeaseID{} }
108+
109+// NavigationDirective requests one client-owned route transition. Opening the
110+// home picker always carries a fresh lease; Back is sent by the transient home
111+// route and therefore carries no lease.
112+type NavigationDirective struct {
113+ Action NavigationAction
114+ LeaseID ParkedRouteLeaseID
115+}
116+
117+// ParkedRouteAction advances one retained route through its one-shot lease.
118+type ParkedRouteAction uint8
119+
120+const (
121+ ParkedRoutePrepare ParkedRouteAction = iota + 1
122+ ParkedRouteResume
123+ ParkedRouteSwitch
124+)
125+
126+// ParkedRouteRequest prepares, resumes, or switches the retained authenticated
127+// route. Target is present only for Switch and retains exact lifecycle/tab
128+// authority from the home picker's remote catalogue row.
129+type ParkedRouteRequest struct {
130+ RequestID uint64
131+ LeaseID ParkedRouteLeaseID
132+ Action ParkedRouteAction
133+ Target *domain.RemoteSessionTarget
134+}
135+
136+// ParkedRouteStatus is the explicit outcome for a retained-route request.
137+type ParkedRouteStatus uint8
138+
139+const (
140+ ParkedRouteReady ParkedRouteStatus = iota + 1
141+ ParkedRouteResumed
142+ ParkedRouteSwitched
143+ ParkedRouteRejected
144+ ParkedRouteExpired
145+ ParkedRouteStaleTarget
146+)
147+
148+// ParkedRouteResponse correlates one retained-route request with its outcome.
149+type ParkedRouteResponse struct {
150+ RequestID uint64
151+ Status ParkedRouteStatus
152+}
153+
100154 // RemotePreviewSchemaVersion is independent from the attachment IPC version.
101155 const RemotePreviewSchemaVersion uint16 = 1
102156
@@ -12,7 +12,7 @@ import (
12 const HandshakeTimeout = 15 * time.Second12 const HandshakeTimeout = 15 * time.Second
13 13
14 // ProtocolVersion is the current vev IPC wire protocol version.14 // ProtocolVersion is the current vev IPC wire protocol version.
15-const ProtocolVersion uint16 = 3315+const ProtocolVersion uint16 = 34
16 16
17 // MaxFrameLen is the largest permitted frame length, including the type byte17 // MaxFrameLen is the largest permitted frame length, including the type byte
18 // and excluding the four-byte length prefix.18 // and excluding the four-byte length prefix.
@@ -22,8 +22,8 @@ const MaxFrameLen = 16 << 20
22 type MsgType uint822 type MsgType uint8
23 23
24 // Frame message types use non-contiguous allocations: the legacy client range24 // Frame message types use non-contiguous allocations: the legacy client range
25-// is 1–13 and 15, and newer client controls use 32–33. Server-originated25+// is 1–13 and 15, and newer client controls use 32–33 and 35. Server-originated
26-// messages occupy 16–23, 25–31, and 34. Values 14 and 24 remain reserved for26+// messages occupy 16–23, 25–31, 34, and 36. Values 14 and 24 remain reserved for
27 // future extensions.27 // future extensions.
28 const (28 const (
29 MsgHello MsgType = 129 MsgHello MsgType = 1
@@ -42,6 +42,7 @@ const (
42 MsgRemotePreviewRequest MsgType = 1542 MsgRemotePreviewRequest MsgType = 15
43 MsgRouteAttentionSubscription MsgType = 3243 MsgRouteAttentionSubscription MsgType = 32
44 MsgSamePeerSwitchRequest MsgType = 3344 MsgSamePeerSwitchRequest MsgType = 33
45+ MsgParkedRouteRequest MsgType = 35
45 46
46 MsgWelcome MsgType = 1647 MsgWelcome MsgType = 16
47 MsgError MsgType = 1748 MsgError MsgType = 17
@@ -61,6 +62,7 @@ const (
61 MsgRouteNavigationFailure MsgType = 3062 MsgRouteNavigationFailure MsgType = 30
62 MsgRoutePosition MsgType = 3163 MsgRoutePosition MsgType = 31
63 MsgSamePeerSwitchFailure MsgType = 3464 MsgSamePeerSwitchFailure MsgType = 34
65+ MsgParkedRouteResponse MsgType = 36
64 )66 )
65 67
66 // Frame is the unit of exchange over a Transport: a typed, length-delimited68 // Frame is the unit of exchange over a Transport: a typed, length-delimited
@@ -97,6 +99,58 @@ const (
97 NavigationBack NavigationAction = 299 NavigationBack NavigationAction = 2
98 )100 )
99 101
102+// ParkedRouteLeaseID is an opaque capability minted by the attached daemon for
103+// one home-picker excursion. It is bound server-side to the exact connection
104+// generation and transport that received the navigation directive.
105+type ParkedRouteLeaseID [16]byte
106+
107+func (id ParkedRouteLeaseID) IsZero() bool { return id == ParkedRouteLeaseID{} }
108+
109+// NavigationDirective requests one client-owned route transition. Opening the
110+// home picker always carries a fresh lease; Back is sent by the transient home
111+// route and therefore carries no lease.
112+type NavigationDirective struct {
113+ Action NavigationAction
114+ LeaseID ParkedRouteLeaseID
115+}
116+
117+// ParkedRouteAction advances one retained route through its one-shot lease.
118+type ParkedRouteAction uint8
119+
120+const (
121+ ParkedRoutePrepare ParkedRouteAction = iota + 1
122+ ParkedRouteResume
123+ ParkedRouteSwitch
124+)
125+
126+// ParkedRouteRequest prepares, resumes, or switches the retained authenticated
127+// route. Target is present only for Switch and retains exact lifecycle/tab
128+// authority from the home picker's remote catalogue row.
129+type ParkedRouteRequest struct {
130+ RequestID uint64
131+ LeaseID ParkedRouteLeaseID
132+ Action ParkedRouteAction
133+ Target *domain.RemoteSessionTarget
134+}
135+
136+// ParkedRouteStatus is the explicit outcome for a retained-route request.
137+type ParkedRouteStatus uint8
138+
139+const (
140+ ParkedRouteReady ParkedRouteStatus = iota + 1
141+ ParkedRouteResumed
142+ ParkedRouteSwitched
143+ ParkedRouteRejected
144+ ParkedRouteExpired
145+ ParkedRouteStaleTarget
146+)
147+
148+// ParkedRouteResponse correlates one retained-route request with its outcome.
149+type ParkedRouteResponse struct {
150+ RequestID uint64
151+ Status ParkedRouteStatus
152+}
153+
100 // RemotePreviewSchemaVersion is independent from the attachment IPC version.154 // RemotePreviewSchemaVersion is independent from the attachment IPC version.
101 const RemotePreviewSchemaVersion uint16 = 1155 const RemotePreviewSchemaVersion uint16 = 1
102 156
M internal/ports/frame_test.go +5 -4
@@ -3,8 +3,8 @@ package ports
33 import "testing"
44
55 func TestProtocolVersion(t *testing.T) {
6- if ProtocolVersion != 33 {
7- t.Fatalf("ProtocolVersion = %d, want 33", ProtocolVersion)
6+ if ProtocolVersion != 34 {
7+ t.Fatalf("ProtocolVersion = %d, want 34", ProtocolVersion)
88 }
99 }
1010
@@ -32,14 +32,15 @@ func TestMsgTypeUnique(t *testing.T) {
3232 {"MsgDetach", MsgDetach}, {"MsgPing", MsgPing}, {"MsgList", MsgList},
3333 {"MsgKill", MsgKill}, {"MsgTheme", MsgTheme}, {"MsgAck", MsgAck},
3434 {"MsgImagePush", MsgImagePush}, {"MsgClientNotice", MsgClientNotice},
35- {"MsgCommand", MsgCommand}, {"MsgOutputResetRequest", MsgOutputResetRequest}, {"MsgRemotePreviewRequest", MsgRemotePreviewRequest}, {"MsgRouteAttentionSubscription", MsgRouteAttentionSubscription},
35+ {"MsgCommand", MsgCommand}, {"MsgOutputResetRequest", MsgOutputResetRequest}, {"MsgRemotePreviewRequest", MsgRemotePreviewRequest},
36+ {"MsgRouteAttentionSubscription", MsgRouteAttentionSubscription}, {"MsgSamePeerSwitchRequest", MsgSamePeerSwitchRequest}, {"MsgParkedRouteRequest", MsgParkedRouteRequest},
3637 {"MsgWelcome", MsgWelcome}, {"MsgError", MsgError}, {"MsgOutput", MsgOutput},
3738 {"MsgDetached", MsgDetached}, {"MsgPong", MsgPong}, {"MsgSessions", MsgSessions},
3839 {"MsgCommandResult", MsgCommandResult},
3940 {"MsgRemotePreviewResponse", MsgRemotePreviewResponse}, {"MsgAttachTarget", MsgAttachTarget}, {"MsgNavigationAction", MsgNavigationAction},
4041 {"MsgCommittedRouteIdentity", MsgCommittedRouteIdentity}, {"MsgRecentRouteSnapshot", MsgRecentRouteSnapshot},
4142 {"MsgNavigateRecentRoute", MsgNavigateRecentRoute}, {"MsgRouteNavigationFailure", MsgRouteNavigationFailure},
42- {"MsgRoutePosition", MsgRoutePosition},
43+ {"MsgRoutePosition", MsgRoutePosition}, {"MsgSamePeerSwitchFailure", MsgSamePeerSwitchFailure}, {"MsgParkedRouteResponse", MsgParkedRouteResponse},
4344 }
4445
4546 seen := make(map[MsgType]string, len(tests))
@@ -3,8 +3,8 @@ package ports
3 import "testing"3 import "testing"
4 4
5 func TestProtocolVersion(t *testing.T) {5 func TestProtocolVersion(t *testing.T) {
6- if ProtocolVersion != 33 {6+ if ProtocolVersion != 34 {
7- t.Fatalf("ProtocolVersion = %d, want 33", ProtocolVersion)7+ t.Fatalf("ProtocolVersion = %d, want 34", ProtocolVersion)
8 }8 }
9 }9 }
10 10
@@ -32,14 +32,15 @@ func TestMsgTypeUnique(t *testing.T) {
32 {"MsgDetach", MsgDetach}, {"MsgPing", MsgPing}, {"MsgList", MsgList},32 {"MsgDetach", MsgDetach}, {"MsgPing", MsgPing}, {"MsgList", MsgList},
33 {"MsgKill", MsgKill}, {"MsgTheme", MsgTheme}, {"MsgAck", MsgAck},33 {"MsgKill", MsgKill}, {"MsgTheme", MsgTheme}, {"MsgAck", MsgAck},
34 {"MsgImagePush", MsgImagePush}, {"MsgClientNotice", MsgClientNotice},34 {"MsgImagePush", MsgImagePush}, {"MsgClientNotice", MsgClientNotice},
35- {"MsgCommand", MsgCommand}, {"MsgOutputResetRequest", MsgOutputResetRequest}, {"MsgRemotePreviewRequest", MsgRemotePreviewRequest}, {"MsgRouteAttentionSubscription", MsgRouteAttentionSubscription},35+ {"MsgCommand", MsgCommand}, {"MsgOutputResetRequest", MsgOutputResetRequest}, {"MsgRemotePreviewRequest", MsgRemotePreviewRequest},
36+ {"MsgRouteAttentionSubscription", MsgRouteAttentionSubscription}, {"MsgSamePeerSwitchRequest", MsgSamePeerSwitchRequest}, {"MsgParkedRouteRequest", MsgParkedRouteRequest},
36 {"MsgWelcome", MsgWelcome}, {"MsgError", MsgError}, {"MsgOutput", MsgOutput},37 {"MsgWelcome", MsgWelcome}, {"MsgError", MsgError}, {"MsgOutput", MsgOutput},
37 {"MsgDetached", MsgDetached}, {"MsgPong", MsgPong}, {"MsgSessions", MsgSessions},38 {"MsgDetached", MsgDetached}, {"MsgPong", MsgPong}, {"MsgSessions", MsgSessions},
38 {"MsgCommandResult", MsgCommandResult},39 {"MsgCommandResult", MsgCommandResult},
39 {"MsgRemotePreviewResponse", MsgRemotePreviewResponse}, {"MsgAttachTarget", MsgAttachTarget}, {"MsgNavigationAction", MsgNavigationAction},40 {"MsgRemotePreviewResponse", MsgRemotePreviewResponse}, {"MsgAttachTarget", MsgAttachTarget}, {"MsgNavigationAction", MsgNavigationAction},
40 {"MsgCommittedRouteIdentity", MsgCommittedRouteIdentity}, {"MsgRecentRouteSnapshot", MsgRecentRouteSnapshot},41 {"MsgCommittedRouteIdentity", MsgCommittedRouteIdentity}, {"MsgRecentRouteSnapshot", MsgRecentRouteSnapshot},
41 {"MsgNavigateRecentRoute", MsgNavigateRecentRoute}, {"MsgRouteNavigationFailure", MsgRouteNavigationFailure},42 {"MsgNavigateRecentRoute", MsgNavigateRecentRoute}, {"MsgRouteNavigationFailure", MsgRouteNavigationFailure},
42- {"MsgRoutePosition", MsgRoutePosition},43+ {"MsgRoutePosition", MsgRoutePosition}, {"MsgSamePeerSwitchFailure", MsgSamePeerSwitchFailure}, {"MsgParkedRouteResponse", MsgParkedRouteResponse},
43 }44 }
44 45
45 seen := make(map[MsgType]string, len(tests))46 seen := make(map[MsgType]string, len(tests))
M internal/ports/navigation_test.go +108 -18
@@ -8,39 +8,129 @@ import (
88 "github.com/stretchr/testify/require"
99 )
1010
11-func TestNavigationActionWireTable(t *testing.T) {
11+func TestNavigationDirectiveWireTable(t *testing.T) {
12+ lease := ParkedRouteLeaseID{1, 2, 3}
1213 tests := []struct {
13- name string
14- action NavigationAction
15- payload []byte
16- valid bool
14+ name string
15+ directive NavigationDirective
16+ valid bool
1717 }{
18- {name: "open home", action: NavigationOpenHomePicker, payload: []byte{1}, valid: true},
19- {name: "back", action: NavigationBack, payload: []byte{2}, valid: true},
20- {name: "zero", action: 0, payload: []byte{0}, valid: false},
21- {name: "unknown", action: 3, payload: []byte{3}, valid: false},
18+ {name: "open home", directive: NavigationDirective{Action: NavigationOpenHomePicker, LeaseID: lease}, valid: true},
19+ {name: "back", directive: NavigationDirective{Action: NavigationBack}, valid: true},
20+ {name: "open home without lease", directive: NavigationDirective{Action: NavigationOpenHomePicker}},
21+ {name: "back with lease", directive: NavigationDirective{Action: NavigationBack, LeaseID: lease}},
22+ {name: "zero", directive: NavigationDirective{}},
23+ {name: "unknown", directive: NavigationDirective{Action: 3}},
2224 }
2325
2426 for _, tt := range tests {
2527 t.Run(tt.name, func(t *testing.T) {
26- got := MarshalNavigationAction(tt.action)
28+ got := MarshalNavigationDirective(tt.directive)
2729 if !tt.valid {
2830 require.Nil(t, got)
29- _, err := UnmarshalNavigationAction(tt.payload)
30- require.ErrorIs(t, err, ErrInvalidNavigation)
3131 return
3232 }
33- require.Equal(t, tt.payload, got)
34- decoded, err := UnmarshalNavigationAction(got)
33+ decoded, err := UnmarshalNavigationDirective(got)
3534 require.NoError(t, err)
36- require.Equal(t, tt.action, decoded)
37- assertAllPrefixesFail(t, got, UnmarshalNavigationAction)
38- _, err = UnmarshalNavigationAction(append(append([]byte(nil), got...), 0))
35+ require.Equal(t, tt.directive, decoded)
36+ assertAllPrefixesFail(t, got, UnmarshalNavigationDirective)
37+ _, err = UnmarshalNavigationDirective(append(append([]byte(nil), got...), 0))
3938 require.ErrorIs(t, err, ErrInvalidNavigation)
4039 })
4140 }
4241 }
4342
43+func TestParkedRouteWireRoundTripsStrictly(t *testing.T) {
44+ lease := ParkedRouteLeaseID{1, 2, 3}
45+ target := &domain.RemoteSessionTarget{
46+ Endpoint: "remote", DisplayOrigin: "remote", LifecycleID: domain.SessionLifecycleID{4},
47+ SessionName: "work", LiveTabID: "tab-1",
48+ }
49+ requests := []struct {
50+ name string
51+ value ParkedRouteRequest
52+ golden string
53+ }{
54+ {name: "prepare", value: ParkedRouteRequest{RequestID: 1, LeaseID: lease, Action: ParkedRoutePrepare}, golden: "0000000000000001" + "01020300000000000000000000000000" + "010001"},
55+ {name: "resume", value: ParkedRouteRequest{RequestID: 2, LeaseID: lease, Action: ParkedRouteResume}},
56+ {name: "switch", value: ParkedRouteRequest{RequestID: 3, LeaseID: lease, Action: ParkedRouteSwitch, Target: target}},
57+ }
58+ for _, tt := range requests {
59+ t.Run("request "+tt.name, func(t *testing.T) {
60+ payload := MarshalParkedRouteRequest(tt.value)
61+ require.NotNil(t, payload)
62+ if tt.golden != "" {
63+ golden, err := hex.DecodeString(tt.golden)
64+ require.NoError(t, err)
65+ require.Equal(t, golden, payload)
66+ }
67+ decoded, err := UnmarshalParkedRouteRequest(payload)
68+ require.NoError(t, err)
69+ require.Equal(t, tt.value, decoded)
70+ assertAllPrefixesFail(t, payload, UnmarshalParkedRouteRequest)
71+ assertTrailingGarbageFails(t, payload, UnmarshalParkedRouteRequest)
72+ })
73+ }
74+
75+ responses := []struct {
76+ name string
77+ value ParkedRouteResponse
78+ golden string
79+ }{
80+ {name: "ready", value: ParkedRouteResponse{RequestID: 1, Status: ParkedRouteReady}, golden: "000000000000000101"},
81+ {name: "resumed", value: ParkedRouteResponse{RequestID: 2, Status: ParkedRouteResumed}},
82+ {name: "switched", value: ParkedRouteResponse{RequestID: 3, Status: ParkedRouteSwitched}},
83+ {name: "rejected", value: ParkedRouteResponse{RequestID: 4, Status: ParkedRouteRejected}},
84+ {name: "expired", value: ParkedRouteResponse{RequestID: 5, Status: ParkedRouteExpired}},
85+ {name: "stale target", value: ParkedRouteResponse{RequestID: 6, Status: ParkedRouteStaleTarget}},
86+ }
87+ for _, tt := range responses {
88+ t.Run("response "+tt.name, func(t *testing.T) {
89+ payload := MarshalParkedRouteResponse(tt.value)
90+ if tt.golden != "" {
91+ golden, err := hex.DecodeString(tt.golden)
92+ require.NoError(t, err)
93+ require.Equal(t, golden, payload)
94+ }
95+ decoded, err := UnmarshalParkedRouteResponse(payload)
96+ require.NoError(t, err)
97+ require.Equal(t, tt.value, decoded)
98+ assertAllPrefixesFail(t, payload, UnmarshalParkedRouteResponse)
99+ assertTrailingGarbageFails(t, payload, UnmarshalParkedRouteResponse)
100+ })
101+ }
102+
103+ invalidRequests := []struct {
104+ name string
105+ value ParkedRouteRequest
106+ }{
107+ {name: "zero request ID", value: ParkedRouteRequest{LeaseID: lease, Action: ParkedRoutePrepare}},
108+ {name: "zero lease", value: ParkedRouteRequest{RequestID: 1, Action: ParkedRoutePrepare}},
109+ {name: "unknown action", value: ParkedRouteRequest{RequestID: 1, LeaseID: lease, Action: 99}},
110+ {name: "prepare target", value: ParkedRouteRequest{RequestID: 1, LeaseID: lease, Action: ParkedRoutePrepare, Target: target}},
111+ {name: "resume target", value: ParkedRouteRequest{RequestID: 1, LeaseID: lease, Action: ParkedRouteResume, Target: target}},
112+ {name: "missing switch target", value: ParkedRouteRequest{RequestID: 1, LeaseID: lease, Action: ParkedRouteSwitch}},
113+ {name: "invalid switch target", value: ParkedRouteRequest{RequestID: 1, LeaseID: lease, Action: ParkedRouteSwitch, Target: &domain.RemoteSessionTarget{}}},
114+ }
115+ for _, tt := range invalidRequests {
116+ t.Run("invalid request "+tt.name, func(t *testing.T) {
117+ require.Nil(t, MarshalParkedRouteRequest(tt.value))
118+ })
119+ }
120+ for _, tt := range []struct {
121+ name string
122+ value ParkedRouteResponse
123+ }{
124+ {name: "zero request ID", value: ParkedRouteResponse{Status: ParkedRouteReady}},
125+ {name: "zero status", value: ParkedRouteResponse{RequestID: 1}},
126+ {name: "unknown status", value: ParkedRouteResponse{RequestID: 1, Status: 99}},
127+ } {
128+ t.Run("invalid response "+tt.name, func(t *testing.T) {
129+ require.Nil(t, MarshalParkedRouteResponse(tt.value))
130+ })
131+ }
132+}
133+
44134 func TestHelloNavigationValidationTable(t *testing.T) {
45135 base := Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}}
46136 remoteTarget := &domain.RemoteSessionTarget{
@@ -62,7 +152,7 @@ func TestHelloNavigationValidationTable(t *testing.T) {
62152 {name: "client-owned home picker rejected", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityHomePicker}, valid: false},
63153 {name: "daemon-owned back picker rejected", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker, EnvironmentPolicy: EnvironmentPolicyDaemonOwned}, valid: false},
64154 {name: "remote-target back picker rejected", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Name: "work", Size: domain.Size{Cols: 80, Rows: 24}, RemoteTarget: remoteTarget, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker, EnvironmentPolicy: EnvironmentPolicyDaemonOwned}, valid: false},
65- {name: "back with startup picker", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker}, valid: true, capability: NavigationCapabilityBack, overlay: StartupOverlaySessionPicker, wantPayload: "002102" + "00000000000000000000000000000000" + "0000000000000000" + "0000" + "00500018" + "0000" + "0000" + "00" + "00" + "00000000" + "00" + "00" + "00" + "0000" + "0201"},
155+ {name: "back with startup picker", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker}, valid: true, capability: NavigationCapabilityBack, overlay: StartupOverlaySessionPicker, wantPayload: "002202" + "00000000000000000000000000000000" + "0000000000000000" + "0000" + "00500018" + "0000" + "0000" + "00" + "00" + "00000000" + "00" + "00" + "00" + "0000" + "0201"},
66156 {name: "new rejects navigation", hello: Hello{Version: ProtocolVersion, Intent: IntentNew, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker}, valid: false},
67157 {name: "unknown capability", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: 4}, valid: false},
68158 {name: "back without picker", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack}, valid: false},
@@ -8,39 +8,129 @@ import (
8 "github.com/stretchr/testify/require"8 "github.com/stretchr/testify/require"
9 )9 )
10 10
11-func TestNavigationActionWireTable(t *testing.T) {11+func TestNavigationDirectiveWireTable(t *testing.T) {
12+ lease := ParkedRouteLeaseID{1, 2, 3}
12 tests := []struct {13 tests := []struct {
13- name string14+ name string
14- action NavigationAction15+ directive NavigationDirective
15- payload []byte16+ valid bool
16- valid bool
17 }{17 }{
18- {name: "open home", action: NavigationOpenHomePicker, payload: []byte{1}, valid: true},18+ {name: "open home", directive: NavigationDirective{Action: NavigationOpenHomePicker, LeaseID: lease}, valid: true},
19- {name: "back", action: NavigationBack, payload: []byte{2}, valid: true},19+ {name: "back", directive: NavigationDirective{Action: NavigationBack}, valid: true},
20- {name: "zero", action: 0, payload: []byte{0}, valid: false},20+ {name: "open home without lease", directive: NavigationDirective{Action: NavigationOpenHomePicker}},
21- {name: "unknown", action: 3, payload: []byte{3}, valid: false},21+ {name: "back with lease", directive: NavigationDirective{Action: NavigationBack, LeaseID: lease}},
22+ {name: "zero", directive: NavigationDirective{}},
23+ {name: "unknown", directive: NavigationDirective{Action: 3}},
22 }24 }
23 25
24 for _, tt := range tests {26 for _, tt := range tests {
25 t.Run(tt.name, func(t *testing.T) {27 t.Run(tt.name, func(t *testing.T) {
26- got := MarshalNavigationAction(tt.action)28+ got := MarshalNavigationDirective(tt.directive)
27 if !tt.valid {29 if !tt.valid {
28 require.Nil(t, got)30 require.Nil(t, got)
29- _, err := UnmarshalNavigationAction(tt.payload)
30- require.ErrorIs(t, err, ErrInvalidNavigation)
31 return31 return
32 }32 }
33- require.Equal(t, tt.payload, got)33+ decoded, err := UnmarshalNavigationDirective(got)
34- decoded, err := UnmarshalNavigationAction(got)
35 require.NoError(t, err)34 require.NoError(t, err)
36- require.Equal(t, tt.action, decoded)35+ require.Equal(t, tt.directive, decoded)
37- assertAllPrefixesFail(t, got, UnmarshalNavigationAction)36+ assertAllPrefixesFail(t, got, UnmarshalNavigationDirective)
38- _, err = UnmarshalNavigationAction(append(append([]byte(nil), got...), 0))37+ _, err = UnmarshalNavigationDirective(append(append([]byte(nil), got...), 0))
39 require.ErrorIs(t, err, ErrInvalidNavigation)38 require.ErrorIs(t, err, ErrInvalidNavigation)
40 })39 })
41 }40 }
42 }41 }
43 42
43+func TestParkedRouteWireRoundTripsStrictly(t *testing.T) {
44+ lease := ParkedRouteLeaseID{1, 2, 3}
45+ target := &domain.RemoteSessionTarget{
46+ Endpoint: "remote", DisplayOrigin: "remote", LifecycleID: domain.SessionLifecycleID{4},
47+ SessionName: "work", LiveTabID: "tab-1",
48+ }
49+ requests := []struct {
50+ name string
51+ value ParkedRouteRequest
52+ golden string
53+ }{
54+ {name: "prepare", value: ParkedRouteRequest{RequestID: 1, LeaseID: lease, Action: ParkedRoutePrepare}, golden: "0000000000000001" + "01020300000000000000000000000000" + "010001"},
55+ {name: "resume", value: ParkedRouteRequest{RequestID: 2, LeaseID: lease, Action: ParkedRouteResume}},
56+ {name: "switch", value: ParkedRouteRequest{RequestID: 3, LeaseID: lease, Action: ParkedRouteSwitch, Target: target}},
57+ }
58+ for _, tt := range requests {
59+ t.Run("request "+tt.name, func(t *testing.T) {
60+ payload := MarshalParkedRouteRequest(tt.value)
61+ require.NotNil(t, payload)
62+ if tt.golden != "" {
63+ golden, err := hex.DecodeString(tt.golden)
64+ require.NoError(t, err)
65+ require.Equal(t, golden, payload)
66+ }
67+ decoded, err := UnmarshalParkedRouteRequest(payload)
68+ require.NoError(t, err)
69+ require.Equal(t, tt.value, decoded)
70+ assertAllPrefixesFail(t, payload, UnmarshalParkedRouteRequest)
71+ assertTrailingGarbageFails(t, payload, UnmarshalParkedRouteRequest)
72+ })
73+ }
74+
75+ responses := []struct {
76+ name string
77+ value ParkedRouteResponse
78+ golden string
79+ }{
80+ {name: "ready", value: ParkedRouteResponse{RequestID: 1, Status: ParkedRouteReady}, golden: "000000000000000101"},
81+ {name: "resumed", value: ParkedRouteResponse{RequestID: 2, Status: ParkedRouteResumed}},
82+ {name: "switched", value: ParkedRouteResponse{RequestID: 3, Status: ParkedRouteSwitched}},
83+ {name: "rejected", value: ParkedRouteResponse{RequestID: 4, Status: ParkedRouteRejected}},
84+ {name: "expired", value: ParkedRouteResponse{RequestID: 5, Status: ParkedRouteExpired}},
85+ {name: "stale target", value: ParkedRouteResponse{RequestID: 6, Status: ParkedRouteStaleTarget}},
86+ }
87+ for _, tt := range responses {
88+ t.Run("response "+tt.name, func(t *testing.T) {
89+ payload := MarshalParkedRouteResponse(tt.value)
90+ if tt.golden != "" {
91+ golden, err := hex.DecodeString(tt.golden)
92+ require.NoError(t, err)
93+ require.Equal(t, golden, payload)
94+ }
95+ decoded, err := UnmarshalParkedRouteResponse(payload)
96+ require.NoError(t, err)
97+ require.Equal(t, tt.value, decoded)
98+ assertAllPrefixesFail(t, payload, UnmarshalParkedRouteResponse)
99+ assertTrailingGarbageFails(t, payload, UnmarshalParkedRouteResponse)
100+ })
101+ }
102+
103+ invalidRequests := []struct {
104+ name string
105+ value ParkedRouteRequest
106+ }{
107+ {name: "zero request ID", value: ParkedRouteRequest{LeaseID: lease, Action: ParkedRoutePrepare}},
108+ {name: "zero lease", value: ParkedRouteRequest{RequestID: 1, Action: ParkedRoutePrepare}},
109+ {name: "unknown action", value: ParkedRouteRequest{RequestID: 1, LeaseID: lease, Action: 99}},
110+ {name: "prepare target", value: ParkedRouteRequest{RequestID: 1, LeaseID: lease, Action: ParkedRoutePrepare, Target: target}},
111+ {name: "resume target", value: ParkedRouteRequest{RequestID: 1, LeaseID: lease, Action: ParkedRouteResume, Target: target}},
112+ {name: "missing switch target", value: ParkedRouteRequest{RequestID: 1, LeaseID: lease, Action: ParkedRouteSwitch}},
113+ {name: "invalid switch target", value: ParkedRouteRequest{RequestID: 1, LeaseID: lease, Action: ParkedRouteSwitch, Target: &domain.RemoteSessionTarget{}}},
114+ }
115+ for _, tt := range invalidRequests {
116+ t.Run("invalid request "+tt.name, func(t *testing.T) {
117+ require.Nil(t, MarshalParkedRouteRequest(tt.value))
118+ })
119+ }
120+ for _, tt := range []struct {
121+ name string
122+ value ParkedRouteResponse
123+ }{
124+ {name: "zero request ID", value: ParkedRouteResponse{Status: ParkedRouteReady}},
125+ {name: "zero status", value: ParkedRouteResponse{RequestID: 1}},
126+ {name: "unknown status", value: ParkedRouteResponse{RequestID: 1, Status: 99}},
127+ } {
128+ t.Run("invalid response "+tt.name, func(t *testing.T) {
129+ require.Nil(t, MarshalParkedRouteResponse(tt.value))
130+ })
131+ }
132+}
133+
44 func TestHelloNavigationValidationTable(t *testing.T) {134 func TestHelloNavigationValidationTable(t *testing.T) {
45 base := Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}}135 base := Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}}
46 remoteTarget := &domain.RemoteSessionTarget{136 remoteTarget := &domain.RemoteSessionTarget{
@@ -62,7 +152,7 @@ func TestHelloNavigationValidationTable(t *testing.T) {
62 {name: "client-owned home picker rejected", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityHomePicker}, valid: false},152 {name: "client-owned home picker rejected", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityHomePicker}, valid: false},
63 {name: "daemon-owned back picker rejected", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker, EnvironmentPolicy: EnvironmentPolicyDaemonOwned}, valid: false},153 {name: "daemon-owned back picker rejected", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker, EnvironmentPolicy: EnvironmentPolicyDaemonOwned}, valid: false},
64 {name: "remote-target back picker rejected", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Name: "work", Size: domain.Size{Cols: 80, Rows: 24}, RemoteTarget: remoteTarget, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker, EnvironmentPolicy: EnvironmentPolicyDaemonOwned}, valid: false},154 {name: "remote-target back picker rejected", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Name: "work", Size: domain.Size{Cols: 80, Rows: 24}, RemoteTarget: remoteTarget, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker, EnvironmentPolicy: EnvironmentPolicyDaemonOwned}, valid: false},
65- {name: "back with startup picker", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker}, valid: true, capability: NavigationCapabilityBack, overlay: StartupOverlaySessionPicker, wantPayload: "002102" + "00000000000000000000000000000000" + "0000000000000000" + "0000" + "00500018" + "0000" + "0000" + "00" + "00" + "00000000" + "00" + "00" + "00" + "0000" + "0201"},155+ {name: "back with startup picker", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker}, valid: true, capability: NavigationCapabilityBack, overlay: StartupOverlaySessionPicker, wantPayload: "002202" + "00000000000000000000000000000000" + "0000000000000000" + "0000" + "00500018" + "0000" + "0000" + "00" + "00" + "00000000" + "00" + "00" + "00" + "0000" + "0201"},
66 {name: "new rejects navigation", hello: Hello{Version: ProtocolVersion, Intent: IntentNew, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker}, valid: false},156 {name: "new rejects navigation", hello: Hello{Version: ProtocolVersion, Intent: IntentNew, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker}, valid: false},
67 {name: "unknown capability", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: 4}, valid: false},157 {name: "unknown capability", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: 4}, valid: false},
68 {name: "back without picker", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack}, valid: false},158 {name: "back without picker", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack}, valid: false},
M internal/ports/wire.go +113 -12
@@ -2066,27 +2066,128 @@ func UnmarshalRemotePreview(data []byte) (RemotePreview, error) {
20662066 return p, nil
20672067 }
20682068
2069-// MarshalNavigationAction encodes one bounded navigation action.
2070-func MarshalNavigationAction(action NavigationAction) []byte {
2071- if action != NavigationOpenHomePicker && action != NavigationBack {
2069+// MarshalNavigationDirective encodes one bounded navigation directive.
2070+func MarshalNavigationDirective(directive NavigationDirective) []byte {
2071+ if directive.Action != NavigationOpenHomePicker && directive.Action != NavigationBack {
20722072 return nil
20732073 }
2074- return []byte{byte(action)}
2074+ if directive.Action == NavigationOpenHomePicker && directive.LeaseID.IsZero() || directive.Action == NavigationBack && !directive.LeaseID.IsZero() {
2075+ return nil
2076+ }
2077+ w := payloadWriter{}
2078+ w.putUint8(uint8(directive.Action))
2079+ w.putBytes(directive.LeaseID[:])
2080+ return w.b
20752081 }
20762082
2077-// UnmarshalNavigationAction decodes one strict navigation action payload.
2078-func UnmarshalNavigationAction(b []byte) (NavigationAction, error) {
2083+// UnmarshalNavigationDirective decodes one strict navigation directive.
2084+func UnmarshalNavigationDirective(b []byte) (NavigationDirective, error) {
20792085 r := payloadReader{b: b}
20802086 value, err := r.getUint8()
20812087 if err != nil {
2082- return 0, ErrInvalidNavigation
2088+ return NavigationDirective{}, ErrInvalidNavigation
2089+ }
2090+ lease, err := r.getBytes(len(ParkedRouteLeaseID{}))
2091+ if err != nil {
2092+ return NavigationDirective{}, ErrInvalidNavigation
20832093 }
20842094 if err := r.done(); err != nil {
2085- return 0, ErrInvalidNavigation
2095+ return NavigationDirective{}, ErrInvalidNavigation
2096+ }
2097+ directive := NavigationDirective{Action: NavigationAction(value)}
2098+ copy(directive.LeaseID[:], lease)
2099+ if MarshalNavigationDirective(directive) == nil {
2100+ return NavigationDirective{}, ErrInvalidNavigation
2101+ }
2102+ return directive, nil
2103+}
2104+
2105+// ValidateParkedRouteRequest enforces the closed action/target shape before a
2106+// request reaches either side's route state machine.
2107+func ValidateParkedRouteRequest(request ParkedRouteRequest) error {
2108+ if request.RequestID == 0 || request.LeaseID.IsZero() {
2109+ return ErrInvalidNavigation
2110+ }
2111+ switch request.Action {
2112+ case ParkedRoutePrepare, ParkedRouteResume:
2113+ if request.Target != nil {
2114+ return ErrInvalidNavigation
2115+ }
2116+ case ParkedRouteSwitch:
2117+ if request.Target == nil || validateWireRemoteTarget(*request.Target) != nil {
2118+ return ErrInvalidNavigation
2119+ }
2120+ default:
2121+ return ErrInvalidNavigation
2122+ }
2123+ return nil
2124+}
2125+
2126+// MarshalParkedRouteRequest encodes one retained-route operation.
2127+func MarshalParkedRouteRequest(request ParkedRouteRequest) []byte {
2128+ if ValidateParkedRouteRequest(request) != nil {
2129+ return nil
2130+ }
2131+ w := payloadWriter{}
2132+ w.putUint64(request.RequestID)
2133+ w.putBytes(request.LeaseID[:])
2134+ w.putUint8(uint8(request.Action))
2135+ marshalRemoteTargetSection(&w, request.Target, EnvironmentPolicyDaemonOwned)
2136+ return w.b
2137+}
2138+
2139+// UnmarshalParkedRouteRequest decodes one strict retained-route operation.
2140+func UnmarshalParkedRouteRequest(b []byte) (ParkedRouteRequest, error) {
2141+ r := payloadReader{b: b}
2142+ requestID, err := r.getUint64()
2143+ if err != nil {
2144+ return ParkedRouteRequest{}, ErrInvalidNavigation
2145+ }
2146+ lease, err := r.getBytes(len(ParkedRouteLeaseID{}))
2147+ if err != nil {
2148+ return ParkedRouteRequest{}, ErrInvalidNavigation
2149+ }
2150+ action, err := r.getUint8()
2151+ if err != nil {
2152+ return ParkedRouteRequest{}, ErrInvalidNavigation
2153+ }
2154+ target, policy, err := unmarshalRemoteTargetSection(&r)
2155+ if err != nil || policy != EnvironmentPolicyDaemonOwned || r.done() != nil {
2156+ return ParkedRouteRequest{}, ErrInvalidNavigation
2157+ }
2158+ request := ParkedRouteRequest{RequestID: requestID, Action: ParkedRouteAction(action), Target: target}
2159+ copy(request.LeaseID[:], lease)
2160+ if ValidateParkedRouteRequest(request) != nil {
2161+ return ParkedRouteRequest{}, ErrInvalidNavigation
2162+ }
2163+ return request, nil
2164+}
2165+
2166+// MarshalParkedRouteResponse encodes one correlated retained-route outcome.
2167+func MarshalParkedRouteResponse(response ParkedRouteResponse) []byte {
2168+ if response.RequestID == 0 || response.Status < ParkedRouteReady || response.Status > ParkedRouteStaleTarget {
2169+ return nil
2170+ }
2171+ w := payloadWriter{}
2172+ w.putUint64(response.RequestID)
2173+ w.putUint8(uint8(response.Status))
2174+ return w.b
2175+}
2176+
2177+// UnmarshalParkedRouteResponse decodes one strict retained-route outcome.
2178+func UnmarshalParkedRouteResponse(b []byte) (ParkedRouteResponse, error) {
2179+ r := payloadReader{b: b}
2180+ requestID, err := r.getUint64()
2181+ if err != nil {
2182+ return ParkedRouteResponse{}, ErrInvalidNavigation
2183+ }
2184+ status, err := r.getUint8()
2185+ if err != nil || r.done() != nil {
2186+ return ParkedRouteResponse{}, ErrInvalidNavigation
20862187 }
2087- action := NavigationAction(value)
2088- if action != NavigationOpenHomePicker && action != NavigationBack {
2089- return 0, ErrInvalidNavigation
2188+ response := ParkedRouteResponse{RequestID: requestID, Status: ParkedRouteStatus(status)}
2189+ if MarshalParkedRouteResponse(response) == nil {
2190+ return ParkedRouteResponse{}, ErrInvalidNavigation
20902191 }
2091- return action, nil
2192+ return response, nil
20922193 }
@@ -2066,27 +2066,128 @@ func UnmarshalRemotePreview(data []byte) (RemotePreview, error) {
2066 return p, nil2066 return p, nil
2067 }2067 }
2068 2068
2069-// MarshalNavigationAction encodes one bounded navigation action.2069+// MarshalNavigationDirective encodes one bounded navigation directive.
2070-func MarshalNavigationAction(action NavigationAction) []byte {2070+func MarshalNavigationDirective(directive NavigationDirective) []byte {
2071- if action != NavigationOpenHomePicker && action != NavigationBack {2071+ if directive.Action != NavigationOpenHomePicker && directive.Action != NavigationBack {
2072 return nil2072 return nil
2073 }2073 }
2074- return []byte{byte(action)}2074+ if directive.Action == NavigationOpenHomePicker && directive.LeaseID.IsZero() || directive.Action == NavigationBack && !directive.LeaseID.IsZero() {
2075+ return nil
2076+ }
2077+ w := payloadWriter{}
2078+ w.putUint8(uint8(directive.Action))
2079+ w.putBytes(directive.LeaseID[:])
2080+ return w.b
2075 }2081 }
2076 2082
2077-// UnmarshalNavigationAction decodes one strict navigation action payload.2083+// UnmarshalNavigationDirective decodes one strict navigation directive.
2078-func UnmarshalNavigationAction(b []byte) (NavigationAction, error) {2084+func UnmarshalNavigationDirective(b []byte) (NavigationDirective, error) {
2079 r := payloadReader{b: b}2085 r := payloadReader{b: b}
2080 value, err := r.getUint8()2086 value, err := r.getUint8()
2081 if err != nil {2087 if err != nil {
2082- return 0, ErrInvalidNavigation2088+ return NavigationDirective{}, ErrInvalidNavigation
2089+ }
2090+ lease, err := r.getBytes(len(ParkedRouteLeaseID{}))
2091+ if err != nil {
2092+ return NavigationDirective{}, ErrInvalidNavigation
2083 }2093 }
2084 if err := r.done(); err != nil {2094 if err := r.done(); err != nil {
2085- return 0, ErrInvalidNavigation2095+ return NavigationDirective{}, ErrInvalidNavigation
2096+ }
2097+ directive := NavigationDirective{Action: NavigationAction(value)}
2098+ copy(directive.LeaseID[:], lease)
2099+ if MarshalNavigationDirective(directive) == nil {
2100+ return NavigationDirective{}, ErrInvalidNavigation
2101+ }
2102+ return directive, nil
2103+}
2104+
2105+// ValidateParkedRouteRequest enforces the closed action/target shape before a
2106+// request reaches either side's route state machine.
2107+func ValidateParkedRouteRequest(request ParkedRouteRequest) error {
2108+ if request.RequestID == 0 || request.LeaseID.IsZero() {
2109+ return ErrInvalidNavigation
2110+ }
2111+ switch request.Action {
2112+ case ParkedRoutePrepare, ParkedRouteResume:
2113+ if request.Target != nil {
2114+ return ErrInvalidNavigation
2115+ }
2116+ case ParkedRouteSwitch:
2117+ if request.Target == nil || validateWireRemoteTarget(*request.Target) != nil {
2118+ return ErrInvalidNavigation
2119+ }
2120+ default:
2121+ return ErrInvalidNavigation
2122+ }
2123+ return nil
2124+}
2125+
2126+// MarshalParkedRouteRequest encodes one retained-route operation.
2127+func MarshalParkedRouteRequest(request ParkedRouteRequest) []byte {
2128+ if ValidateParkedRouteRequest(request) != nil {
2129+ return nil
2130+ }
2131+ w := payloadWriter{}
2132+ w.putUint64(request.RequestID)
2133+ w.putBytes(request.LeaseID[:])
2134+ w.putUint8(uint8(request.Action))
2135+ marshalRemoteTargetSection(&w, request.Target, EnvironmentPolicyDaemonOwned)
2136+ return w.b
2137+}
2138+
2139+// UnmarshalParkedRouteRequest decodes one strict retained-route operation.
2140+func UnmarshalParkedRouteRequest(b []byte) (ParkedRouteRequest, error) {
2141+ r := payloadReader{b: b}
2142+ requestID, err := r.getUint64()
2143+ if err != nil {
2144+ return ParkedRouteRequest{}, ErrInvalidNavigation
2145+ }
2146+ lease, err := r.getBytes(len(ParkedRouteLeaseID{}))
2147+ if err != nil {
2148+ return ParkedRouteRequest{}, ErrInvalidNavigation
2149+ }
2150+ action, err := r.getUint8()
2151+ if err != nil {
2152+ return ParkedRouteRequest{}, ErrInvalidNavigation
2153+ }
2154+ target, policy, err := unmarshalRemoteTargetSection(&r)
2155+ if err != nil || policy != EnvironmentPolicyDaemonOwned || r.done() != nil {
2156+ return ParkedRouteRequest{}, ErrInvalidNavigation
2157+ }
2158+ request := ParkedRouteRequest{RequestID: requestID, Action: ParkedRouteAction(action), Target: target}
2159+ copy(request.LeaseID[:], lease)
2160+ if ValidateParkedRouteRequest(request) != nil {
2161+ return ParkedRouteRequest{}, ErrInvalidNavigation
2162+ }
2163+ return request, nil
2164+}
2165+
2166+// MarshalParkedRouteResponse encodes one correlated retained-route outcome.
2167+func MarshalParkedRouteResponse(response ParkedRouteResponse) []byte {
2168+ if response.RequestID == 0 || response.Status < ParkedRouteReady || response.Status > ParkedRouteStaleTarget {
2169+ return nil
2170+ }
2171+ w := payloadWriter{}
2172+ w.putUint64(response.RequestID)
2173+ w.putUint8(uint8(response.Status))
2174+ return w.b
2175+}
2176+
2177+// UnmarshalParkedRouteResponse decodes one strict retained-route outcome.
2178+func UnmarshalParkedRouteResponse(b []byte) (ParkedRouteResponse, error) {
2179+ r := payloadReader{b: b}
2180+ requestID, err := r.getUint64()
2181+ if err != nil {
2182+ return ParkedRouteResponse{}, ErrInvalidNavigation
2183+ }
2184+ status, err := r.getUint8()
2185+ if err != nil || r.done() != nil {
2186+ return ParkedRouteResponse{}, ErrInvalidNavigation
2086 }2187 }
2087- action := NavigationAction(value)2188+ response := ParkedRouteResponse{RequestID: requestID, Status: ParkedRouteStatus(status)}
2088- if action != NavigationOpenHomePicker && action != NavigationBack {2189+ if MarshalParkedRouteResponse(response) == nil {
2089- return 0, ErrInvalidNavigation2190+ return ParkedRouteResponse{}, ErrInvalidNavigation
2090 }2191 }
2091- return action, nil2192+ return response, nil
2092 }2193 }
M internal/ports/wire_final_test.go +3 -3
@@ -11,8 +11,8 @@ import (
1111 )
1212
1313 func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) {
14- if ProtocolVersion != 33 {
15- t.Fatalf("ProtocolVersion = %d, want 33", ProtocolVersion)
14+ if ProtocolVersion != 34 {
15+ t.Fatalf("ProtocolVersion = %d, want 34", ProtocolVersion)
1616 }
1717 payload := MarshalHello(Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}})
1818 if len(payload) < 2 {
@@ -26,7 +26,7 @@ func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) {
2626
2727 func TestFinalHelloGoldenStrict(t *testing.T) {
2828 msg := Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}}
29- want := append([]byte{0, 33, 2}, make([]byte, 16+8)...)
29+ want := append([]byte{0, 34, 2}, make([]byte, 16+8)...)
3030 want = append(want, 0, 0, 0, 80, 0, 24, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)
3131 got := MarshalHello(msg)
3232 if !bytes.Equal(got, want) {
@@ -11,8 +11,8 @@ import (
11 )11 )
12 12
13 func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) {13 func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) {
14- if ProtocolVersion != 33 {14+ if ProtocolVersion != 34 {
15- t.Fatalf("ProtocolVersion = %d, want 33", ProtocolVersion)15+ t.Fatalf("ProtocolVersion = %d, want 34", ProtocolVersion)
16 }16 }
17 payload := MarshalHello(Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}})17 payload := MarshalHello(Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}})
18 if len(payload) < 2 {18 if len(payload) < 2 {
@@ -26,7 +26,7 @@ func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) {
26 26
27 func TestFinalHelloGoldenStrict(t *testing.T) {27 func TestFinalHelloGoldenStrict(t *testing.T) {
28 msg := Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}}28 msg := Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}}
29- want := append([]byte{0, 33, 2}, make([]byte, 16+8)...)29+ want := append([]byte{0, 34, 2}, make([]byte, 16+8)...)
30 want = append(want, 0, 0, 0, 80, 0, 24, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)30 want = append(want, 0, 0, 0, 80, 0, 24, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)
31 got := MarshalHello(msg)31 got := MarshalHello(msg)
32 if !bytes.Equal(got, want) {32 if !bytes.Equal(got, want) {
M internal/ports/wire_test.go +6 -6
@@ -131,7 +131,7 @@ func TestHelloEnvironmentCodec(t *testing.T) {
131131 }
132132 got := MarshalHello(msg)
133133 want := []byte{
134- 0x00, 0x21, 0x00, // version, intent, first client ID byte
134+ 0x00, 0x22, 0x00, // version, intent, first client ID byte
135135 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
136136 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID
137137 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token
@@ -361,7 +361,7 @@ func TestThemeGenerationClearedWireGoldenPreservesProtocolVersion(t *testing.T)
361361 }
362362 want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...)
363363 require.Equal(t, want, MarshalTheme(cleared))
364- require.Equal(t, uint16(33), ProtocolVersion)
364+ require.Equal(t, uint16(34), ProtocolVersion)
365365 }
366366
367367 func TestResizeGoldenAndRoundTrip(t *testing.T) {
@@ -522,7 +522,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) {
522522 name: "minimal",
523523 msg: CommandRequest{Version: ProtocolVersion, Slug: "split-right"},
524524 want: []byte{
525- 0x00, 0x21,
525+ 0x00, 0x22,
526526 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID
527527 0x00, // attached
528528 0x00, // self
@@ -545,7 +545,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) {
545545 JSON: true,
546546 },
547547 want: []byte{
548- 0x00, 0x21,
548+ 0x00, 0x22,
549549 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID
550550 0x00, // attached
551551 0x01, // self
@@ -974,7 +974,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) {
974974 name: "attached",
975975 msg: CommandRequest{Version: ProtocolVersion, RequestID: 0x0102030405060708, Attached: true, Self: true, Slug: "split-right", Args: []string{"--vertical"}},
976976 want: []byte{
977- 0x00, 0x21, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x01, 0x01,
977+ 0x00, 0x22, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x01, 0x01,
978978 0x00, 0x0b, 's', 'p', 'l', 'i', 't', '-', 'r', 'i', 'g', 'h', 't',
979979 0x00, 0x01, 0x00, 0x00, 0x00, 0x0a, '-', '-', 'v', 'e', 'r', 't', 'i', 'c', 'a', 'l',
980980 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
@@ -984,7 +984,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) {
984984 name: "control",
985985 msg: CommandRequest{Version: ProtocolVersion, Slug: "ls"},
986986 want: []byte{
987- 0x00, 0x21, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
987+ 0x00, 0x22, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
988988 0x00, 0x02, 'l', 's', 0x00, 0x00,
989989 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
990990 },
@@ -131,7 +131,7 @@ func TestHelloEnvironmentCodec(t *testing.T) {
131 }131 }
132 got := MarshalHello(msg)132 got := MarshalHello(msg)
133 want := []byte{133 want := []byte{
134- 0x00, 0x21, 0x00, // version, intent, first client ID byte134+ 0x00, 0x22, 0x00, // version, intent, first client ID byte
135 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,135 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
136 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID136 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID
137 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token137 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token
@@ -361,7 +361,7 @@ func TestThemeGenerationClearedWireGoldenPreservesProtocolVersion(t *testing.T)
361 }361 }
362 want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...)362 want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...)
363 require.Equal(t, want, MarshalTheme(cleared))363 require.Equal(t, want, MarshalTheme(cleared))
364- require.Equal(t, uint16(33), ProtocolVersion)364+ require.Equal(t, uint16(34), ProtocolVersion)
365 }365 }
366 366
367 func TestResizeGoldenAndRoundTrip(t *testing.T) {367 func TestResizeGoldenAndRoundTrip(t *testing.T) {
@@ -522,7 +522,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) {
522 name: "minimal",522 name: "minimal",
523 msg: CommandRequest{Version: ProtocolVersion, Slug: "split-right"},523 msg: CommandRequest{Version: ProtocolVersion, Slug: "split-right"},
524 want: []byte{524 want: []byte{
525- 0x00, 0x21,525+ 0x00, 0x22,
526 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID526 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID
527 0x00, // attached527 0x00, // attached
528 0x00, // self528 0x00, // self
@@ -545,7 +545,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) {
545 JSON: true,545 JSON: true,
546 },546 },
547 want: []byte{547 want: []byte{
548- 0x00, 0x21,548+ 0x00, 0x22,
549 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID549 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID
550 0x00, // attached550 0x00, // attached
551 0x01, // self551 0x01, // self
@@ -974,7 +974,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) {
974 name: "attached",974 name: "attached",
975 msg: CommandRequest{Version: ProtocolVersion, RequestID: 0x0102030405060708, Attached: true, Self: true, Slug: "split-right", Args: []string{"--vertical"}},975 msg: CommandRequest{Version: ProtocolVersion, RequestID: 0x0102030405060708, Attached: true, Self: true, Slug: "split-right", Args: []string{"--vertical"}},
976 want: []byte{976 want: []byte{
977- 0x00, 0x21, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x01, 0x01,977+ 0x00, 0x22, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x01, 0x01,
978 0x00, 0x0b, 's', 'p', 'l', 'i', 't', '-', 'r', 'i', 'g', 'h', 't',978 0x00, 0x0b, 's', 'p', 'l', 'i', 't', '-', 'r', 'i', 'g', 'h', 't',
979 0x00, 0x01, 0x00, 0x00, 0x00, 0x0a, '-', '-', 'v', 'e', 'r', 't', 'i', 'c', 'a', 'l',979 0x00, 0x01, 0x00, 0x00, 0x00, 0x0a, '-', '-', 'v', 'e', 'r', 't', 'i', 'c', 'a', 'l',
980 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,980 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
@@ -984,7 +984,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) {
984 name: "control",984 name: "control",
985 msg: CommandRequest{Version: ProtocolVersion, Slug: "ls"},985 msg: CommandRequest{Version: ProtocolVersion, Slug: "ls"},
986 want: []byte{986 want: []byte{
987- 0x00, 0x21, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,987+ 0x00, 0x22, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
988 0x00, 0x02, 'l', 's', 0x00, 0x00,988 0x00, 0x02, 'l', 's', 0x00, 0x00,
989 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,989 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
990 },990 },

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