feat(client): add route ledger protocol plumbing
- commit
- 4dbec77c1436c57bb2cb661b4d7600601e9ffeee
- author
- brice · · 2026-08-09 11:51 UTC
- parent
- a27f761
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
21 files · +1760 -59
A .agent-reviews/redteam.md +38 -0
| @@ -0,0 +1,38 @@ | |||
| 1 | + | # Stack 2 route ledger/protocol red-team | |
| 2 | + | ||
| 3 | + | ## Builder decision | |
| 4 | + | ||
| 5 | + | Add Stack 2 as daemon-neutral plumbing on top of the completed presentation branch: | |
| 6 | + | ||
| 7 | + | - model route origins, exact lifecycle/name targets, committed identity, bounded complete snapshots, opaque key/generation navigation actions, and closed failure codes in `internal/ports`; | |
| 8 | + | - bump the protocol version and append an optional exact target section to `Hello` while preserving the version peeker's first field; | |
| 9 | + | - wire explicit local, remote, and discovery origins at `internal/app`/client composition boundaries; | |
| 10 | + | - keep the client ledger private, bounded, lock-protected, and separate from display values and transport capabilities; | |
| 11 | + | - validate exact local targets in the daemon and preserve existing remote-target authority; | |
| 12 | + | - leave JRS/BSK command authority and the atomic global cutover for Stack 3. | |
| 13 | + | ||
| 14 | + | ## Critic pass | |
| 15 | + | ||
| 16 | + | The read-only reviewer worker did not return a retrievable result in this harness. | |
| 17 | + | The builder completed a local high-impact critic pass below and recorded each | |
| 18 | + | objection with its resolution and acceptance check. | |
| 19 | + | ||
| 20 | + | ## Evidence so far | |
| 21 | + | ||
| 22 | + | - PR #188 dependency branch already has strict Hello peeking and remote lifecycle/tab target validation. | |
| 23 | + | - `internal/ports/wire.go` requires `Hello.Version` first; the new exact-target section is appended after the existing remote/policy section. | |
| 24 | + | - Existing daemon route authority remains in `routeWithContext`; exact local validation is an additional lifecycle check before ordinary attach routing. | |
| 25 | + | - Focused ports, client, app, and daemon tests pass after the current incremental changes. | |
| 26 | + | ||
| 27 | + | ## Accepted risks | |
| 28 | + | ||
| 29 | + | - Stack 2 introduces codec/model seams and private transition primitives but does not yet publish snapshots or replace command authority; those remain explicit Stack 3 work. | |
| 30 | + | - The first reviewer-worker attempts did not produce a retrievable result in this harness, so the builder performed the required critic pass locally before final validation. | |
| 31 | + | ||
| 32 | + | ## Builder critic pass and resolutions | |
| 33 | + | ||
| 34 | + | - **Welcome identity mismatch:** a malicious or buggy daemon could return a committed lifecycle/name different from the Welcome metadata. `UnmarshalWelcome` now rejects mismatched session name or ephemeral state. | |
| 35 | + | - **Stale exact target side effects:** exact local validation waits for an existing restore barrier before checking lifecycle identity; it does not create or mutate a session and rejects lifecycle replacement before normal attach. | |
| 36 | + | - **Snapshot leakage:** `RecentRouteEntry` contains only opaque key/generation and display fields. Dialers, requests, resume credentials, and exact targets remain in private `routeRecord` values and are copied out only through navigation lookup. | |
| 37 | + | - **Transactional navigation:** connector failure, stale key/generation, or committed-target mismatch returns before ledger commit; origin is restored from the selected private record rather than connector input. | |
| 38 | + | - **Bounds/strictness:** snapshot entry count, labels, refs, closed enums, bool encodings, truncation, trailing bytes, and protocol-version peeking are covered by tests; focused race tests pass. | |
| @@ -0,0 +1,38 @@ | |||||
| 1 | + | # Stack 2 route ledger/protocol red-team | |||
| 2 | + | ||||
| 3 | + | ## Builder decision | |||
| 4 | + | ||||
| 5 | + | Add Stack 2 as daemon-neutral plumbing on top of the completed presentation branch: | |||
| 6 | + | ||||
| 7 | + | - model route origins, exact lifecycle/name targets, committed identity, bounded complete snapshots, opaque key/generation navigation actions, and closed failure codes in `internal/ports`; | |||
| 8 | + | - bump the protocol version and append an optional exact target section to `Hello` while preserving the version peeker's first field; | |||
| 9 | + | - wire explicit local, remote, and discovery origins at `internal/app`/client composition boundaries; | |||
| 10 | + | - keep the client ledger private, bounded, lock-protected, and separate from display values and transport capabilities; | |||
| 11 | + | - validate exact local targets in the daemon and preserve existing remote-target authority; | |||
| 12 | + | - leave JRS/BSK command authority and the atomic global cutover for Stack 3. | |||
| 13 | + | ||||
| 14 | + | ## Critic pass | |||
| 15 | + | ||||
| 16 | + | The read-only reviewer worker did not return a retrievable result in this harness. | |||
| 17 | + | The builder completed a local high-impact critic pass below and recorded each | |||
| 18 | + | objection with its resolution and acceptance check. | |||
| 19 | + | ||||
| 20 | + | ## Evidence so far | |||
| 21 | + | ||||
| 22 | + | - PR #188 dependency branch already has strict Hello peeking and remote lifecycle/tab target validation. | |||
| 23 | + | - `internal/ports/wire.go` requires `Hello.Version` first; the new exact-target section is appended after the existing remote/policy section. | |||
| 24 | + | - Existing daemon route authority remains in `routeWithContext`; exact local validation is an additional lifecycle check before ordinary attach routing. | |||
| 25 | + | - Focused ports, client, app, and daemon tests pass after the current incremental changes. | |||
| 26 | + | ||||
| 27 | + | ## Accepted risks | |||
| 28 | + | ||||
| 29 | + | - Stack 2 introduces codec/model seams and private transition primitives but does not yet publish snapshots or replace command authority; those remain explicit Stack 3 work. | |||
| 30 | + | - The first reviewer-worker attempts did not produce a retrievable result in this harness, so the builder performed the required critic pass locally before final validation. | |||
| 31 | + | ||||
| 32 | + | ## Builder critic pass and resolutions | |||
| 33 | + | ||||
| 34 | + | - **Welcome identity mismatch:** a malicious or buggy daemon could return a committed lifecycle/name different from the Welcome metadata. `UnmarshalWelcome` now rejects mismatched session name or ephemeral state. | |||
| 35 | + | - **Stale exact target side effects:** exact local validation waits for an existing restore barrier before checking lifecycle identity; it does not create or mutate a session and rejects lifecycle replacement before normal attach. | |||
| 36 | + | - **Snapshot leakage:** `RecentRouteEntry` contains only opaque key/generation and display fields. Dialers, requests, resume credentials, and exact targets remain in private `routeRecord` values and are copied out only through navigation lookup. | |||
| 37 | + | - **Transactional navigation:** connector failure, stale key/generation, or committed-target mismatch returns before ledger commit; origin is restored from the selected private record rather than connector input. | |||
| 38 | + | - **Bounds/strictness:** snapshot entry count, labels, refs, closed enums, bool encodings, truncation, trailing bytes, and protocol-version peeking are covered by tests; focused race tests pass. | |||
M docs/remote-resilience.md +4 -1
| @@ -32,7 +32,10 @@ is a temporary direct handoff; the client keeps one input pump and remembers a | |||
| 32 | 32 | bounded home route. A remote daemon may request the home picker, and cancelling | |
| 33 | 33 | that temporary local picker returns to the parked remote route, first attempting | |
| 34 | 34 | resume and then falling back to a fresh attach when the resume token is stale. | |
| 35 | - | These navigation actions and the strict handshake layout use protocol v26. | |
| 35 | + | These navigation actions and the strict handshake layout use protocol v27. | |
| 36 | + | Attach handshakes may carry an exact lifecycle/name target, and successful | |
| 37 | + | Welcomes can return the daemon's committed identity; the client keeps bounded | |
| 38 | + | route identity and display snapshots separate from transport capabilities. | |
| 36 | 39 | ||
| 37 | 40 | ## Durable record compatibility | |
| 38 | 41 | ||
| @@ -32,7 +32,10 @@ is a temporary direct handoff; the client keeps one input pump and remembers a | |||||
| 32 | bounded home route. A remote daemon may request the home picker, and cancelling | 32 | bounded home route. A remote daemon may request the home picker, and cancelling | ||
| 33 | that temporary local picker returns to the parked remote route, first attempting | 33 | that temporary local picker returns to the parked remote route, first attempting | ||
| 34 | resume and then falling back to a fresh attach when the resume token is stale. | 34 | resume and then falling back to a fresh attach when the resume token is stale. | ||
| 35 | - | These navigation actions and the strict handshake layout use protocol v26. | 35 | + | These navigation actions and the strict handshake layout use protocol v27. |
| 36 | + | Attach handshakes may carry an exact lifecycle/name target, and successful | |||
| 37 | + | Welcomes can return the daemon's committed identity; the client keeps bounded | |||
| 38 | + | route identity and display snapshots separate from transport capabilities. | |||
| 36 | 39 | ||||
| 37 | ## Durable record compatibility | 40 | ## Durable record compatibility | ||
| 38 | 41 | ||||
M internal/app/run.go +15 -2
| @@ -866,6 +866,7 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac | |||
| 866 | 866 | factory := deps.remoteDialerFactory | |
| 867 | 867 | var remoteSelection *domain.RemoteSessionTarget | |
| 868 | 868 | var remoteEnvironmentPolicy ports.EnvironmentPolicy | |
| 869 | + | routeOrigin := ports.RouteOriginLocal | |
| 869 | 870 | seenHandoffs := make(map[attachHandoffKey]struct{}) | |
| 870 | 871 | handoffHops := 0 | |
| 871 | 872 | admitHandoff := func(next attachHandoffKey) error { | |
| @@ -880,6 +881,7 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac | |||
| 880 | 881 | return nil | |
| 881 | 882 | } | |
| 882 | 883 | if remoteTarget != "" { | |
| 884 | + | routeOrigin = ports.RouteOriginRemote | |
| 883 | 885 | if modeErr != nil { | |
| 884 | 886 | return modeErr | |
| 885 | 887 | } | |
| @@ -922,7 +924,14 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac | |||
| 922 | 924 | return nil, client.AttachRequest{}, err | |
| 923 | 925 | } | |
| 924 | 926 | policy := pickerEnvironmentPolicy(selection, target.EnvironmentPolicy) | |
| 925 | - | return dialer, client.AttachRequest{Intent: target.Intent, SessionName: target.Session, Remote: true, RemoteTarget: selection, EnvironmentPolicy: policy}, nil | |
| 927 | + | return dialer, client.AttachRequest{ | |
| 928 | + | Intent: target.Intent, | |
| 929 | + | SessionName: target.Session, | |
| 930 | + | Remote: true, | |
| 931 | + | Origin: ports.RouteOriginDiscovery, | |
| 932 | + | RemoteTarget: selection, | |
| 933 | + | EnvironmentPolicy: policy, | |
| 934 | + | }, nil | |
| 926 | 935 | } | |
| 927 | 936 | ||
| 928 | 937 | for { | |
| @@ -945,10 +954,12 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac | |||
| 945 | 954 | RemoteHostLearner: attachRememberLearner(deps, remoteTarget, log), | |
| 946 | 955 | AttachHandoff: handoff, | |
| 947 | 956 | Remote: true, | |
| 957 | + | Origin: ports.RouteOriginRemote, | |
| 948 | 958 | }, client.AttachRequest{ | |
| 949 | 959 | Intent: intent, | |
| 950 | 960 | SessionName: name, | |
| 951 | 961 | Remote: true, | |
| 962 | + | Origin: routeOrigin, | |
| 952 | 963 | RemoteTarget: remoteSelection, | |
| 953 | 964 | EnvironmentPolicy: remoteEnvironmentPolicy, | |
| 954 | 965 | }) | |
| @@ -964,7 +975,8 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac | |||
| 964 | 975 | RuntimeObserver: deps.runtimeObserver, | |
| 965 | 976 | AttachHandoff: handoff, | |
| 966 | 977 | Remote: false, | |
| 967 | - | }, client.AttachRequest{Intent: intent, SessionName: name}) | |
| 978 | + | Origin: ports.RouteOriginLocal, | |
| 979 | + | }, client.AttachRequest{Intent: intent, SessionName: name, Origin: ports.RouteOriginLocal}) | |
| 968 | 980 | } | |
| 969 | 981 | ||
| 970 | 982 | var handoffErr *client.AttachTargetError | |
| @@ -987,6 +999,7 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac | |||
| 987 | 999 | return err | |
| 988 | 1000 | } | |
| 989 | 1001 | remoteTarget = handoffErr.Target.Endpoint | |
| 1002 | + | routeOrigin = ports.RouteOriginDiscovery | |
| 990 | 1003 | name = handoffErr.Target.Session | |
| 991 | 1004 | intent = handoffErr.Target.Intent | |
| 992 | 1005 | remoteSelection = nil | |
| @@ -866,6 +866,7 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac | |||||
| 866 | factory := deps.remoteDialerFactory | 866 | factory := deps.remoteDialerFactory | ||
| 867 | var remoteSelection *domain.RemoteSessionTarget | 867 | var remoteSelection *domain.RemoteSessionTarget | ||
| 868 | var remoteEnvironmentPolicy ports.EnvironmentPolicy | 868 | var remoteEnvironmentPolicy ports.EnvironmentPolicy | ||
| 869 | + | routeOrigin := ports.RouteOriginLocal | |||
| 869 | seenHandoffs := make(map[attachHandoffKey]struct{}) | 870 | seenHandoffs := make(map[attachHandoffKey]struct{}) | ||
| 870 | handoffHops := 0 | 871 | handoffHops := 0 | ||
| 871 | admitHandoff := func(next attachHandoffKey) error { | 872 | admitHandoff := func(next attachHandoffKey) error { | ||
| @@ -880,6 +881,7 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac | |||||
| 880 | return nil | 881 | return nil | ||
| 881 | } | 882 | } | ||
| 882 | if remoteTarget != "" { | 883 | if remoteTarget != "" { | ||
| 884 | + | routeOrigin = ports.RouteOriginRemote | |||
| 883 | if modeErr != nil { | 885 | if modeErr != nil { | ||
| 884 | return modeErr | 886 | return modeErr | ||
| 885 | } | 887 | } | ||
| @@ -922,7 +924,14 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac | |||||
| 922 | return nil, client.AttachRequest{}, err | 924 | return nil, client.AttachRequest{}, err | ||
| 923 | } | 925 | } | ||
| 924 | policy := pickerEnvironmentPolicy(selection, target.EnvironmentPolicy) | 926 | policy := pickerEnvironmentPolicy(selection, target.EnvironmentPolicy) | ||
| 925 | - | return dialer, client.AttachRequest{Intent: target.Intent, SessionName: target.Session, Remote: true, RemoteTarget: selection, EnvironmentPolicy: policy}, nil | 927 | + | return dialer, client.AttachRequest{ |
| 928 | + | Intent: target.Intent, | |||
| 929 | + | SessionName: target.Session, | |||
| 930 | + | Remote: true, | |||
| 931 | + | Origin: ports.RouteOriginDiscovery, | |||
| 932 | + | RemoteTarget: selection, | |||
| 933 | + | EnvironmentPolicy: policy, | |||
| 934 | + | }, nil | |||
| 926 | } | 935 | } | ||
| 927 | 936 | ||||
| 928 | for { | 937 | for { | ||
| @@ -945,10 +954,12 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac | |||||
| 945 | RemoteHostLearner: attachRememberLearner(deps, remoteTarget, log), | 954 | RemoteHostLearner: attachRememberLearner(deps, remoteTarget, log), | ||
| 946 | AttachHandoff: handoff, | 955 | AttachHandoff: handoff, | ||
| 947 | Remote: true, | 956 | Remote: true, | ||
| 957 | + | Origin: ports.RouteOriginRemote, | |||
| 948 | }, client.AttachRequest{ | 958 | }, client.AttachRequest{ | ||
| 949 | Intent: intent, | 959 | Intent: intent, | ||
| 950 | SessionName: name, | 960 | SessionName: name, | ||
| 951 | Remote: true, | 961 | Remote: true, | ||
| 962 | + | Origin: routeOrigin, | |||
| 952 | RemoteTarget: remoteSelection, | 963 | RemoteTarget: remoteSelection, | ||
| 953 | EnvironmentPolicy: remoteEnvironmentPolicy, | 964 | EnvironmentPolicy: remoteEnvironmentPolicy, | ||
| 954 | }) | 965 | }) | ||
| @@ -964,7 +975,8 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac | |||||
| 964 | RuntimeObserver: deps.runtimeObserver, | 975 | RuntimeObserver: deps.runtimeObserver, | ||
| 965 | AttachHandoff: handoff, | 976 | AttachHandoff: handoff, | ||
| 966 | Remote: false, | 977 | Remote: false, | ||
| 967 | - | }, client.AttachRequest{Intent: intent, SessionName: name}) | 978 | + | Origin: ports.RouteOriginLocal, |
| 979 | + | }, client.AttachRequest{Intent: intent, SessionName: name, Origin: ports.RouteOriginLocal}) | |||
| 968 | } | 980 | } | ||
| 969 | 981 | ||||
| 970 | var handoffErr *client.AttachTargetError | 982 | var handoffErr *client.AttachTargetError | ||
| @@ -987,6 +999,7 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac | |||||
| 987 | return err | 999 | return err | ||
| 988 | } | 1000 | } | ||
| 989 | remoteTarget = handoffErr.Target.Endpoint | 1001 | remoteTarget = handoffErr.Target.Endpoint | ||
| 1002 | + | routeOrigin = ports.RouteOriginDiscovery | |||
| 990 | name = handoffErr.Target.Session | 1003 | name = handoffErr.Target.Session | ||
| 991 | intent = handoffErr.Target.Intent | 1004 | intent = handoffErr.Target.Intent | ||
| 992 | remoteSelection = nil | 1005 | remoteSelection = nil | ||
M internal/app/run_test.go +2 -2
| @@ -959,11 +959,11 @@ func TestRunAttachWithDepsLocalPickerHandoffAttachesSelectedRemote(t *testing.T) | |||
| 959 | 959 | runClient: func(_ context.Context, deps client.Dependencies, request client.AttachRequest) error { | |
| 960 | 960 | if deps.Remote { | |
| 961 | 961 | remoteCalls++ | |
| 962 | - | require.Equal(t, client.AttachRequest{Intent: ports.IntentAttach, SessionName: "picked", Remote: true, EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned}, request) | |
| 962 | + | require.Equal(t, client.AttachRequest{Intent: ports.IntentAttach, SessionName: "picked", Remote: true, Origin: ports.RouteOriginDiscovery, EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned}, request) | |
| 963 | 963 | return nil | |
| 964 | 964 | } | |
| 965 | 965 | localCalls++ | |
| 966 | - | require.Equal(t, client.AttachRequest{Intent: ports.IntentAttach, SessionName: "work"}, request) | |
| 966 | + | require.Equal(t, client.AttachRequest{Intent: ports.IntentAttach, SessionName: "work", Origin: ports.RouteOriginLocal}, request) | |
| 967 | 967 | return &client.AttachTargetError{Target: ports.AttachTarget{Endpoint: "selected.example", Session: "picked", Intent: ports.IntentAttach}} | |
| 968 | 968 | }, | |
| 969 | 969 | }) | |
| @@ -959,11 +959,11 @@ func TestRunAttachWithDepsLocalPickerHandoffAttachesSelectedRemote(t *testing.T) | |||||
| 959 | runClient: func(_ context.Context, deps client.Dependencies, request client.AttachRequest) error { | 959 | runClient: func(_ context.Context, deps client.Dependencies, request client.AttachRequest) error { | ||
| 960 | if deps.Remote { | 960 | if deps.Remote { | ||
| 961 | remoteCalls++ | 961 | remoteCalls++ | ||
| 962 | - | require.Equal(t, client.AttachRequest{Intent: ports.IntentAttach, SessionName: "picked", Remote: true, EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned}, request) | 962 | + | require.Equal(t, client.AttachRequest{Intent: ports.IntentAttach, SessionName: "picked", Remote: true, Origin: ports.RouteOriginDiscovery, EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned}, request) |
| 963 | return nil | 963 | return nil | ||
| 964 | } | 964 | } | ||
| 965 | localCalls++ | 965 | localCalls++ | ||
| 966 | - | require.Equal(t, client.AttachRequest{Intent: ports.IntentAttach, SessionName: "work"}, request) | 966 | + | require.Equal(t, client.AttachRequest{Intent: ports.IntentAttach, SessionName: "work", Origin: ports.RouteOriginLocal}, request) |
| 967 | return &client.AttachTargetError{Target: ports.AttachTarget{Endpoint: "selected.example", Session: "picked", Intent: ports.IntentAttach}} | 967 | return &client.AttachTargetError{Target: ports.AttachTarget{Endpoint: "selected.example", Session: "picked", Intent: ports.IntentAttach}} | ||
| 968 | }, | 968 | }, | ||
| 969 | }) | 969 | }) | ||
M internal/ports/frame.go +18 -13
| @@ -12,7 +12,7 @@ import ( | |||
| 12 | 12 | const HandshakeTimeout = 15 * time.Second | |
| 13 | 13 | ||
| 14 | 14 | // ProtocolVersion is the current vev IPC wire protocol version. | |
| 15 | - | const ProtocolVersion uint16 = 26 | |
| 15 | + | const ProtocolVersion uint16 = 27 | |
| 16 | 16 | ||
| 17 | 17 | // MaxFrameLen is the largest permitted frame length, including the type byte | |
| 18 | 18 | // and excluding the four-byte length prefix. | |
| @@ -22,8 +22,9 @@ const MaxFrameLen = 16 << 20 | |||
| 22 | 22 | type MsgType uint8 | |
| 23 | 23 | ||
| 24 | 24 | // Frame message types. Client-originated messages occupy 1–13 and 15; | |
| 25 | - | // server-originated messages occupy 16–23 and 25–26. Values 14 and 24 remain | |
| 26 | - | // reserved for future extensions. | |
| 25 | + | // server-originated messages occupy 16–23 and 25–27. Values 14 and 24 remain | |
| 26 | + | // reserved for future extensions. Route snapshot/navigation messages use the | |
| 27 | + | // next available values 28–30 and remain daemon-neutral until the cutover. | |
| 27 | 28 | const ( | |
| 28 | 29 | MsgHello MsgType = 1 | |
| 29 | 30 | MsgInput MsgType = 2 | |
| @@ -40,16 +41,20 @@ const ( | |||
| 40 | 41 | MsgOutputResetRequest MsgType = 13 | |
| 41 | 42 | MsgRemotePreviewRequest MsgType = 15 | |
| 42 | 43 | ||
| 43 | - | MsgWelcome MsgType = 16 | |
| 44 | - | MsgError MsgType = 17 | |
| 45 | - | MsgOutput MsgType = 18 | |
| 46 | - | MsgDetached MsgType = 19 | |
| 47 | - | MsgPong MsgType = 20 | |
| 48 | - | MsgSessions MsgType = 21 | |
| 49 | - | MsgCommandResult MsgType = 22 | |
| 50 | - | MsgAttachTarget MsgType = 25 | |
| 51 | - | MsgRemotePreviewResponse MsgType = 26 | |
| 52 | - | MsgNavigationAction MsgType = 23 | |
| 44 | + | MsgWelcome MsgType = 16 | |
| 45 | + | MsgError MsgType = 17 | |
| 46 | + | MsgOutput MsgType = 18 | |
| 47 | + | MsgDetached MsgType = 19 | |
| 48 | + | MsgPong MsgType = 20 | |
| 49 | + | MsgSessions MsgType = 21 | |
| 50 | + | MsgCommandResult MsgType = 22 | |
| 51 | + | MsgAttachTarget MsgType = 25 | |
| 52 | + | MsgRemotePreviewResponse MsgType = 26 | |
| 53 | + | MsgNavigationAction MsgType = 23 | |
| 54 | + | MsgCommittedRouteIdentity MsgType = 27 | |
| 55 | + | MsgRecentRouteSnapshot MsgType = 28 | |
| 56 | + | MsgNavigateRecentRoute MsgType = 29 | |
| 57 | + | MsgRouteNavigationFailure MsgType = 30 | |
| 53 | 58 | ) | |
| 54 | 59 | ||
| 55 | 60 | // Frame is the unit of exchange over a Transport: a typed, length-delimited | |
| @@ -12,7 +12,7 @@ import ( | |||||
| 12 | const HandshakeTimeout = 15 * time.Second | 12 | 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 = 26 | 15 | + | const ProtocolVersion uint16 = 27 |
| 16 | 16 | ||||
| 17 | // MaxFrameLen is the largest permitted frame length, including the type byte | 17 | // 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,9 @@ const MaxFrameLen = 16 << 20 | |||||
| 22 | type MsgType uint8 | 22 | type MsgType uint8 | ||
| 23 | 23 | ||||
| 24 | // Frame message types. Client-originated messages occupy 1–13 and 15; | 24 | // Frame message types. Client-originated messages occupy 1–13 and 15; | ||
| 25 | - | // server-originated messages occupy 16–23 and 25–26. Values 14 and 24 remain | 25 | + | // server-originated messages occupy 16–23 and 25–27. Values 14 and 24 remain |
| 26 | - | // reserved for future extensions. | 26 | + | // reserved for future extensions. Route snapshot/navigation messages use the |
| 27 | + | // next available values 28–30 and remain daemon-neutral until the cutover. | |||
| 27 | const ( | 28 | const ( | ||
| 28 | MsgHello MsgType = 1 | 29 | MsgHello MsgType = 1 | ||
| 29 | MsgInput MsgType = 2 | 30 | MsgInput MsgType = 2 | ||
| @@ -40,16 +41,20 @@ const ( | |||||
| 40 | MsgOutputResetRequest MsgType = 13 | 41 | MsgOutputResetRequest MsgType = 13 | ||
| 41 | MsgRemotePreviewRequest MsgType = 15 | 42 | MsgRemotePreviewRequest MsgType = 15 | ||
| 42 | 43 | ||||
| 43 | - | MsgWelcome MsgType = 16 | 44 | + | MsgWelcome MsgType = 16 |
| 44 | - | MsgError MsgType = 17 | 45 | + | MsgError MsgType = 17 |
| 45 | - | MsgOutput MsgType = 18 | 46 | + | MsgOutput MsgType = 18 |
| 46 | - | MsgDetached MsgType = 19 | 47 | + | MsgDetached MsgType = 19 |
| 47 | - | MsgPong MsgType = 20 | 48 | + | MsgPong MsgType = 20 |
| 48 | - | MsgSessions MsgType = 21 | 49 | + | MsgSessions MsgType = 21 |
| 49 | - | MsgCommandResult MsgType = 22 | 50 | + | MsgCommandResult MsgType = 22 |
| 50 | - | MsgAttachTarget MsgType = 25 | 51 | + | MsgAttachTarget MsgType = 25 |
| 51 | - | MsgRemotePreviewResponse MsgType = 26 | 52 | + | MsgRemotePreviewResponse MsgType = 26 |
| 52 | - | MsgNavigationAction MsgType = 23 | 53 | + | MsgNavigationAction MsgType = 23 |
| 54 | + | MsgCommittedRouteIdentity MsgType = 27 | |||
| 55 | + | MsgRecentRouteSnapshot MsgType = 28 | |||
| 56 | + | MsgNavigateRecentRoute MsgType = 29 | |||
| 57 | + | MsgRouteNavigationFailure MsgType = 30 | |||
| 53 | ) | 58 | ) | ||
| 54 | 59 | ||||
| 55 | // Frame is the unit of exchange over a Transport: a typed, length-delimited | 60 | // Frame is the unit of exchange over a Transport: a typed, length-delimited | ||
M internal/ports/frame_test.go +4 -2
| @@ -3,8 +3,8 @@ package ports | |||
| 3 | 3 | import "testing" | |
| 4 | 4 | ||
| 5 | 5 | func TestProtocolVersion(t *testing.T) { | |
| 6 | - | if ProtocolVersion != 26 { | |
| 7 | - | t.Fatalf("ProtocolVersion = %d, want 26", ProtocolVersion) | |
| 6 | + | if ProtocolVersion != 27 { | |
| 7 | + | t.Fatalf("ProtocolVersion = %d, want 27", ProtocolVersion) | |
| 8 | 8 | } | |
| 9 | 9 | } | |
| 10 | 10 | ||
| @@ -37,6 +37,8 @@ func TestMsgTypeUnique(t *testing.T) { | |||
| 37 | 37 | {"MsgDetached", MsgDetached}, {"MsgPong", MsgPong}, {"MsgSessions", MsgSessions}, | |
| 38 | 38 | {"MsgCommandResult", MsgCommandResult}, | |
| 39 | 39 | {"MsgRemotePreviewResponse", MsgRemotePreviewResponse}, {"MsgAttachTarget", MsgAttachTarget}, {"MsgNavigationAction", MsgNavigationAction}, | |
| 40 | + | {"MsgCommittedRouteIdentity", MsgCommittedRouteIdentity}, {"MsgRecentRouteSnapshot", MsgRecentRouteSnapshot}, | |
| 41 | + | {"MsgNavigateRecentRoute", MsgNavigateRecentRoute}, {"MsgRouteNavigationFailure", MsgRouteNavigationFailure}, | |
| 40 | 42 | } | |
| 41 | 43 | ||
| 42 | 44 | 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 != 26 { | 6 | + | if ProtocolVersion != 27 { |
| 7 | - | t.Fatalf("ProtocolVersion = %d, want 26", ProtocolVersion) | 7 | + | t.Fatalf("ProtocolVersion = %d, want 27", ProtocolVersion) |
| 8 | } | 8 | } | ||
| 9 | } | 9 | } | ||
| 10 | 10 | ||||
| @@ -37,6 +37,8 @@ func TestMsgTypeUnique(t *testing.T) { | |||||
| 37 | {"MsgDetached", MsgDetached}, {"MsgPong", MsgPong}, {"MsgSessions", MsgSessions}, | 37 | {"MsgDetached", MsgDetached}, {"MsgPong", MsgPong}, {"MsgSessions", MsgSessions}, | ||
| 38 | {"MsgCommandResult", MsgCommandResult}, | 38 | {"MsgCommandResult", MsgCommandResult}, | ||
| 39 | {"MsgRemotePreviewResponse", MsgRemotePreviewResponse}, {"MsgAttachTarget", MsgAttachTarget}, {"MsgNavigationAction", MsgNavigationAction}, | 39 | {"MsgRemotePreviewResponse", MsgRemotePreviewResponse}, {"MsgAttachTarget", MsgAttachTarget}, {"MsgNavigationAction", MsgNavigationAction}, | ||
| 40 | + | {"MsgCommittedRouteIdentity", MsgCommittedRouteIdentity}, {"MsgRecentRouteSnapshot", MsgRecentRouteSnapshot}, | |||
| 41 | + | {"MsgNavigateRecentRoute", MsgNavigateRecentRoute}, {"MsgRouteNavigationFailure", MsgRouteNavigationFailure}, | |||
| 40 | } | 42 | } | ||
| 41 | 43 | ||||
| 42 | seen := make(map[MsgType]string, len(tests)) | 44 | seen := make(map[MsgType]string, len(tests)) | ||
M internal/ports/navigation_test.go +1 -1
| @@ -62,7 +62,7 @@ func TestHelloNavigationValidationTable(t *testing.T) { | |||
| 62 | 62 | {name: "client-owned home picker rejected", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityHomePicker}, valid: false}, | |
| 63 | 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}, | |
| 64 | 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}, | |
| 65 | - | {name: "startup picker requires back", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker}, valid: true, capability: NavigationCapabilityBack, overlay: StartupOverlaySessionPicker, wantPayload: "001a02" + "00000000000000000000000000000000" + "0000000000000000" + "0000" + "00500018" + "0000" + "0000" + "00" + "00" + "00000000" + "00" + "00" + "0201"}, | |
| 65 | + | {name: "startup picker requires back", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker}, valid: true, capability: NavigationCapabilityBack, overlay: StartupOverlaySessionPicker, wantPayload: "001b02" + "00000000000000000000000000000000" + "0000000000000000" + "0000" + "00500018" + "0000" + "0000" + "00" + "00" + "00000000" + "00" + "00" + "00" + "0201"}, | |
| 66 | 66 | {name: "new rejects navigation", hello: Hello{Version: ProtocolVersion, Intent: IntentNew, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker}, valid: false}, | |
| 67 | 67 | {name: "unknown capability", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: 4}, valid: false}, | |
| 68 | 68 | {name: "back without picker", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack}, valid: false}, | |
| @@ -62,7 +62,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}, | 62 | {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}, | 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}, | ||
| 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}, | 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}, | ||
| 65 | - | {name: "startup picker requires back", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker}, valid: true, capability: NavigationCapabilityBack, overlay: StartupOverlaySessionPicker, wantPayload: "001a02" + "00000000000000000000000000000000" + "0000000000000000" + "0000" + "00500018" + "0000" + "0000" + "00" + "00" + "00000000" + "00" + "00" + "0201"}, | 65 | + | {name: "startup picker requires back", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker}, valid: true, capability: NavigationCapabilityBack, overlay: StartupOverlaySessionPicker, wantPayload: "001b02" + "00000000000000000000000000000000" + "0000000000000000" + "0000" + "00500018" + "0000" + "0000" + "00" + "00" + "00000000" + "00" + "00" + "00" + "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}, | 66 | {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}, | 67 | {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}, | 68 | {name: "back without picker", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack}, valid: false}, | ||
M internal/ports/remote_target_wire_test.go +2 -2
| @@ -115,7 +115,7 @@ func TestRemoteTargetWireRejectsClientOwnedEnvironmentPolicy(t *testing.T) { | |||
| 115 | 115 | }) | |
| 116 | 116 | }, | |
| 117 | 117 | unmarshal: func(payload []byte) error { _, err := UnmarshalHello(payload); return err }, | |
| 118 | - | policyOffset: 3, | |
| 118 | + | policyOffset: 4, | |
| 119 | 119 | wantErr: ErrInvalidHello, | |
| 120 | 120 | }, | |
| 121 | 121 | { | |
| @@ -194,7 +194,7 @@ func TestAttachTargetRejectsResumeIntent(t *testing.T) { | |||
| 194 | 194 | } | |
| 195 | 195 | } | |
| 196 | 196 | ||
| 197 | - | func TestTargetWireIncludesRequiredV26Section(t *testing.T) { | |
| 197 | + | func TestTargetWireIncludesRequiredV27Section(t *testing.T) { | |
| 198 | 198 | want := []byte{0, 4, 'h', 'o', 's', 't', 0, 4, 'w', 'o', 'r', 'k', IntentAttach, 0, 0} | |
| 199 | 199 | got := MarshalAttachTarget(AttachTarget{Endpoint: "host", Session: "work", Intent: IntentAttach}) | |
| 200 | 200 | if !bytes.Equal(got, want) { | |
| @@ -115,7 +115,7 @@ func TestRemoteTargetWireRejectsClientOwnedEnvironmentPolicy(t *testing.T) { | |||||
| 115 | }) | 115 | }) | ||
| 116 | }, | 116 | }, | ||
| 117 | unmarshal: func(payload []byte) error { _, err := UnmarshalHello(payload); return err }, | 117 | unmarshal: func(payload []byte) error { _, err := UnmarshalHello(payload); return err }, | ||
| 118 | - | policyOffset: 3, | 118 | + | policyOffset: 4, |
| 119 | wantErr: ErrInvalidHello, | 119 | wantErr: ErrInvalidHello, | ||
| 120 | }, | 120 | }, | ||
| 121 | { | 121 | { | ||
| @@ -194,7 +194,7 @@ func TestAttachTargetRejectsResumeIntent(t *testing.T) { | |||||
| 194 | } | 194 | } | ||
| 195 | } | 195 | } | ||
| 196 | 196 | ||||
| 197 | - | func TestTargetWireIncludesRequiredV26Section(t *testing.T) { | 197 | + | func TestTargetWireIncludesRequiredV27Section(t *testing.T) { |
| 198 | want := []byte{0, 4, 'h', 'o', 's', 't', 0, 4, 'w', 'o', 'r', 'k', IntentAttach, 0, 0} | 198 | want := []byte{0, 4, 'h', 'o', 's', 't', 0, 4, 'w', 'o', 'r', 'k', IntentAttach, 0, 0} | ||
| 199 | got := MarshalAttachTarget(AttachTarget{Endpoint: "host", Session: "work", Intent: IntentAttach}) | 199 | got := MarshalAttachTarget(AttachTarget{Endpoint: "host", Session: "work", Intent: IntentAttach}) | ||
| 200 | if !bytes.Equal(got, want) { | 200 | if !bytes.Equal(got, want) { | ||
A internal/ports/routes.go +201 -0
| @@ -0,0 +1,201 @@ | |||
| 1 | + | package ports | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | "fmt" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/vev/internal/domain" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // RouteOrigin identifies how a client reached a daemon. The origin is client | |
| 11 | + | // composition metadata, not an authority granted to the daemon. | |
| 12 | + | type RouteOrigin uint8 | |
| 13 | + | ||
| 14 | + | const ( | |
| 15 | + | RouteOriginLocal RouteOrigin = iota + 1 | |
| 16 | + | RouteOriginRemote | |
| 17 | + | RouteOriginDiscovery | |
| 18 | + | ) | |
| 19 | + | ||
| 20 | + | func (o RouteOrigin) valid() bool { | |
| 21 | + | switch o { | |
| 22 | + | case RouteOriginLocal, RouteOriginRemote, RouteOriginDiscovery: | |
| 23 | + | return true | |
| 24 | + | default: | |
| 25 | + | return false | |
| 26 | + | } | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | func (o RouteOrigin) Validate() error { | |
| 30 | + | if !o.valid() { | |
| 31 | + | return errors.New("invalid route origin") | |
| 32 | + | } | |
| 33 | + | return nil | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | // RouteKind identifies the kind of target represented by one display entry. | |
| 37 | + | type RouteKind uint8 | |
| 38 | + | ||
| 39 | + | const ( | |
| 40 | + | RouteKindLocal RouteKind = iota + 1 | |
| 41 | + | RouteKindRemote | |
| 42 | + | ) | |
| 43 | + | ||
| 44 | + | func (k RouteKind) valid() bool { | |
| 45 | + | switch k { | |
| 46 | + | case RouteKindLocal, RouteKindRemote: | |
| 47 | + | return true | |
| 48 | + | default: | |
| 49 | + | return false | |
| 50 | + | } | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | func (k RouteKind) Validate() error { | |
| 54 | + | if !k.valid() { | |
| 55 | + | return errors.New("invalid route kind") | |
| 56 | + | } | |
| 57 | + | return nil | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | // RouteReachability is a closed display hint. It is never used as proof that | |
| 61 | + | // a route can be attached; the exact target and daemon still decide that. | |
| 62 | + | type RouteReachability uint8 | |
| 63 | + | ||
| 64 | + | const ( | |
| 65 | + | RouteReachabilityUnknown RouteReachability = iota | |
| 66 | + | RouteReachabilityReachable | |
| 67 | + | RouteReachabilityUnavailable | |
| 68 | + | ) | |
| 69 | + | ||
| 70 | + | func (r RouteReachability) valid() bool { | |
| 71 | + | switch r { | |
| 72 | + | case RouteReachabilityUnknown, RouteReachabilityReachable, RouteReachabilityUnavailable: | |
| 73 | + | return true | |
| 74 | + | default: | |
| 75 | + | return false | |
| 76 | + | } | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | func (r RouteReachability) Validate() error { | |
| 80 | + | if !r.valid() { | |
| 81 | + | return errors.New("invalid route reachability") | |
| 82 | + | } | |
| 83 | + | return nil | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | // ExactSessionTarget is the daemon-neutral identity required to attach to a | |
| 87 | + | // specific session lifecycle. The transport endpoint is deliberately absent: | |
| 88 | + | // the selected route's dialer owns that boundary. | |
| 89 | + | type ExactSessionTarget struct { | |
| 90 | + | LifecycleID domain.SessionLifecycleID | |
| 91 | + | SessionName string | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | func (t ExactSessionTarget) Validate() error { | |
| 95 | + | if t.LifecycleID == (domain.SessionLifecycleID{}) { | |
| 96 | + | return errors.New("missing session lifecycle") | |
| 97 | + | } | |
| 98 | + | if err := domain.ValidateSessionName(t.SessionName); err != nil { | |
| 99 | + | return fmt.Errorf("invalid session name: %w", err) | |
| 100 | + | } | |
| 101 | + | return nil | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | // CommittedRouteIdentity is the identity a daemon commits after accepting an | |
| 105 | + | // attach. It is separate from display data so a stale presentation cannot | |
| 106 | + | // become an attach authority. | |
| 107 | + | type CommittedRouteIdentity struct { | |
| 108 | + | Target ExactSessionTarget | |
| 109 | + | Ephemeral bool | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | func (i CommittedRouteIdentity) Validate() error { | |
| 113 | + | if err := i.Target.Validate(); err != nil { | |
| 114 | + | return err | |
| 115 | + | } | |
| 116 | + | return nil | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | // RouteRef is an opaque client-ledger reference. It is intentionally not a | |
| 120 | + | // lifecycle identifier and is meaningful only with the matching generation. | |
| 121 | + | type RouteRef struct { | |
| 122 | + | Key uint64 | |
| 123 | + | Generation uint64 | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | func (r RouteRef) empty() bool { return r.Key == 0 && r.Generation == 0 } | |
| 127 | + | ||
| 128 | + | func (r RouteRef) Validate() error { | |
| 129 | + | if r.empty() { | |
| 130 | + | return nil | |
| 131 | + | } | |
| 132 | + | if r.Key == 0 || r.Generation == 0 { | |
| 133 | + | return errors.New("route reference must contain key and generation") | |
| 134 | + | } | |
| 135 | + | return nil | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | // RecentRouteEntry is the complete daemon-neutral display view of one route. | |
| 139 | + | // It contains no dialer, credential, endpoint, live session pointer, or attach | |
| 140 | + | // target. The client ledger keeps those private beside this value. | |
| 141 | + | type RecentRouteEntry struct { | |
| 142 | + | Key uint64 | |
| 143 | + | Generation uint64 | |
| 144 | + | Name string | |
| 145 | + | HostLabel string | |
| 146 | + | Kind RouteKind | |
| 147 | + | Ephemeral bool | |
| 148 | + | Attention bool | |
| 149 | + | Reachability RouteReachability | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | // RecentRouteSnapshot is an immutable, bounded publication of the client | |
| 153 | + | // route ledger. Entries are ordered from most recent to least recent. | |
| 154 | + | type RecentRouteSnapshot struct { | |
| 155 | + | Generation uint64 | |
| 156 | + | Active RouteRef | |
| 157 | + | Previous RouteRef | |
| 158 | + | Home RouteRef | |
| 159 | + | Entries []RecentRouteEntry | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | // RouteNavigationAction asks the client to resolve one captured route key and | |
| 163 | + | // generation. It carries no session name, endpoint, or credential. | |
| 164 | + | type RouteNavigationAction struct { | |
| 165 | + | Key uint64 | |
| 166 | + | Generation uint64 | |
| 167 | + | } | |
| 168 | + | ||
| 169 | + | // RouteNavigationFailure is a bounded taxonomy for a rejected or stale route | |
| 170 | + | // action. User-facing text remains local to the receiving client. | |
| 171 | + | type RouteNavigationFailure struct { | |
| 172 | + | Key uint64 | |
| 173 | + | Generation uint64 | |
| 174 | + | Code RouteFailureCode | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | type RouteFailureCode uint8 | |
| 178 | + | ||
| 179 | + | const ( | |
| 180 | + | RouteFailureStaleSelection RouteFailureCode = iota + 1 | |
| 181 | + | RouteFailureNoSuchRoute | |
| 182 | + | RouteFailureUnavailable | |
| 183 | + | RouteFailureTargetChanged | |
| 184 | + | RouteFailureOriginUnavailable | |
| 185 | + | ) | |
| 186 | + | ||
| 187 | + | func (c RouteFailureCode) valid() bool { | |
| 188 | + | switch c { | |
| 189 | + | case RouteFailureStaleSelection, RouteFailureNoSuchRoute, RouteFailureUnavailable, RouteFailureTargetChanged, RouteFailureOriginUnavailable: | |
| 190 | + | return true | |
| 191 | + | default: | |
| 192 | + | return false | |
| 193 | + | } | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | const ( | |
| 197 | + | // RouteSnapshotMaxEntries bounds one immutable publication and the amount | |
| 198 | + | // of work a receiver performs before returning to its transport loop. | |
| 199 | + | RouteSnapshotMaxEntries = 32 | |
| 200 | + | RouteLabelMaxBytes = 128 | |
| 201 | + | ) | |
| @@ -0,0 +1,201 @@ | |||||
| 1 | + | package ports | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "errors" | |||
| 5 | + | "fmt" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/vev/internal/domain" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | // RouteOrigin identifies how a client reached a daemon. The origin is client | |||
| 11 | + | // composition metadata, not an authority granted to the daemon. | |||
| 12 | + | type RouteOrigin uint8 | |||
| 13 | + | ||||
| 14 | + | const ( | |||
| 15 | + | RouteOriginLocal RouteOrigin = iota + 1 | |||
| 16 | + | RouteOriginRemote | |||
| 17 | + | RouteOriginDiscovery | |||
| 18 | + | ) | |||
| 19 | + | ||||
| 20 | + | func (o RouteOrigin) valid() bool { | |||
| 21 | + | switch o { | |||
| 22 | + | case RouteOriginLocal, RouteOriginRemote, RouteOriginDiscovery: | |||
| 23 | + | return true | |||
| 24 | + | default: | |||
| 25 | + | return false | |||
| 26 | + | } | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | func (o RouteOrigin) Validate() error { | |||
| 30 | + | if !o.valid() { | |||
| 31 | + | return errors.New("invalid route origin") | |||
| 32 | + | } | |||
| 33 | + | return nil | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | // RouteKind identifies the kind of target represented by one display entry. | |||
| 37 | + | type RouteKind uint8 | |||
| 38 | + | ||||
| 39 | + | const ( | |||
| 40 | + | RouteKindLocal RouteKind = iota + 1 | |||
| 41 | + | RouteKindRemote | |||
| 42 | + | ) | |||
| 43 | + | ||||
| 44 | + | func (k RouteKind) valid() bool { | |||
| 45 | + | switch k { | |||
| 46 | + | case RouteKindLocal, RouteKindRemote: | |||
| 47 | + | return true | |||
| 48 | + | default: | |||
| 49 | + | return false | |||
| 50 | + | } | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | func (k RouteKind) Validate() error { | |||
| 54 | + | if !k.valid() { | |||
| 55 | + | return errors.New("invalid route kind") | |||
| 56 | + | } | |||
| 57 | + | return nil | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | // RouteReachability is a closed display hint. It is never used as proof that | |||
| 61 | + | // a route can be attached; the exact target and daemon still decide that. | |||
| 62 | + | type RouteReachability uint8 | |||
| 63 | + | ||||
| 64 | + | const ( | |||
| 65 | + | RouteReachabilityUnknown RouteReachability = iota | |||
| 66 | + | RouteReachabilityReachable | |||
| 67 | + | RouteReachabilityUnavailable | |||
| 68 | + | ) | |||
| 69 | + | ||||
| 70 | + | func (r RouteReachability) valid() bool { | |||
| 71 | + | switch r { | |||
| 72 | + | case RouteReachabilityUnknown, RouteReachabilityReachable, RouteReachabilityUnavailable: | |||
| 73 | + | return true | |||
| 74 | + | default: | |||
| 75 | + | return false | |||
| 76 | + | } | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | func (r RouteReachability) Validate() error { | |||
| 80 | + | if !r.valid() { | |||
| 81 | + | return errors.New("invalid route reachability") | |||
| 82 | + | } | |||
| 83 | + | return nil | |||
| 84 | + | } | |||
| 85 | + | ||||
| 86 | + | // ExactSessionTarget is the daemon-neutral identity required to attach to a | |||
| 87 | + | // specific session lifecycle. The transport endpoint is deliberately absent: | |||
| 88 | + | // the selected route's dialer owns that boundary. | |||
| 89 | + | type ExactSessionTarget struct { | |||
| 90 | + | LifecycleID domain.SessionLifecycleID | |||
| 91 | + | SessionName string | |||
| 92 | + | } | |||
| 93 | + | ||||
| 94 | + | func (t ExactSessionTarget) Validate() error { | |||
| 95 | + | if t.LifecycleID == (domain.SessionLifecycleID{}) { | |||
| 96 | + | return errors.New("missing session lifecycle") | |||
| 97 | + | } | |||
| 98 | + | if err := domain.ValidateSessionName(t.SessionName); err != nil { | |||
| 99 | + | return fmt.Errorf("invalid session name: %w", err) | |||
| 100 | + | } | |||
| 101 | + | return nil | |||
| 102 | + | } | |||
| 103 | + | ||||
| 104 | + | // CommittedRouteIdentity is the identity a daemon commits after accepting an | |||
| 105 | + | // attach. It is separate from display data so a stale presentation cannot | |||
| 106 | + | // become an attach authority. | |||
| 107 | + | type CommittedRouteIdentity struct { | |||
| 108 | + | Target ExactSessionTarget | |||
| 109 | + | Ephemeral bool | |||
| 110 | + | } | |||
| 111 | + | ||||
| 112 | + | func (i CommittedRouteIdentity) Validate() error { | |||
| 113 | + | if err := i.Target.Validate(); err != nil { | |||
| 114 | + | return err | |||
| 115 | + | } | |||
| 116 | + | return nil | |||
| 117 | + | } | |||
| 118 | + | ||||
| 119 | + | // RouteRef is an opaque client-ledger reference. It is intentionally not a | |||
| 120 | + | // lifecycle identifier and is meaningful only with the matching generation. | |||
| 121 | + | type RouteRef struct { | |||
| 122 | + | Key uint64 | |||
| 123 | + | Generation uint64 | |||
| 124 | + | } | |||
| 125 | + | ||||
| 126 | + | func (r RouteRef) empty() bool { return r.Key == 0 && r.Generation == 0 } | |||
| 127 | + | ||||
| 128 | + | func (r RouteRef) Validate() error { | |||
| 129 | + | if r.empty() { | |||
| 130 | + | return nil | |||
| 131 | + | } | |||
| 132 | + | if r.Key == 0 || r.Generation == 0 { | |||
| 133 | + | return errors.New("route reference must contain key and generation") | |||
| 134 | + | } | |||
| 135 | + | return nil | |||
| 136 | + | } | |||
| 137 | + | ||||
| 138 | + | // RecentRouteEntry is the complete daemon-neutral display view of one route. | |||
| 139 | + | // It contains no dialer, credential, endpoint, live session pointer, or attach | |||
| 140 | + | // target. The client ledger keeps those private beside this value. | |||
| 141 | + | type RecentRouteEntry struct { | |||
| 142 | + | Key uint64 | |||
| 143 | + | Generation uint64 | |||
| 144 | + | Name string | |||
| 145 | + | HostLabel string | |||
| 146 | + | Kind RouteKind | |||
| 147 | + | Ephemeral bool | |||
| 148 | + | Attention bool | |||
| 149 | + | Reachability RouteReachability | |||
| 150 | + | } | |||
| 151 | + | ||||
| 152 | + | // RecentRouteSnapshot is an immutable, bounded publication of the client | |||
| 153 | + | // route ledger. Entries are ordered from most recent to least recent. | |||
| 154 | + | type RecentRouteSnapshot struct { | |||
| 155 | + | Generation uint64 | |||
| 156 | + | Active RouteRef | |||
| 157 | + | Previous RouteRef | |||
| 158 | + | Home RouteRef | |||
| 159 | + | Entries []RecentRouteEntry | |||
| 160 | + | } | |||
| 161 | + | ||||
| 162 | + | // RouteNavigationAction asks the client to resolve one captured route key and | |||
| 163 | + | // generation. It carries no session name, endpoint, or credential. | |||
| 164 | + | type RouteNavigationAction struct { | |||
| 165 | + | Key uint64 | |||
| 166 | + | Generation uint64 | |||
| 167 | + | } | |||
| 168 | + | ||||
| 169 | + | // RouteNavigationFailure is a bounded taxonomy for a rejected or stale route | |||
| 170 | + | // action. User-facing text remains local to the receiving client. | |||
| 171 | + | type RouteNavigationFailure struct { | |||
| 172 | + | Key uint64 | |||
| 173 | + | Generation uint64 | |||
| 174 | + | Code RouteFailureCode | |||
| 175 | + | } | |||
| 176 | + | ||||
| 177 | + | type RouteFailureCode uint8 | |||
| 178 | + | ||||
| 179 | + | const ( | |||
| 180 | + | RouteFailureStaleSelection RouteFailureCode = iota + 1 | |||
| 181 | + | RouteFailureNoSuchRoute | |||
| 182 | + | RouteFailureUnavailable | |||
| 183 | + | RouteFailureTargetChanged | |||
| 184 | + | RouteFailureOriginUnavailable | |||
| 185 | + | ) | |||
| 186 | + | ||||
| 187 | + | func (c RouteFailureCode) valid() bool { | |||
| 188 | + | switch c { | |||
| 189 | + | case RouteFailureStaleSelection, RouteFailureNoSuchRoute, RouteFailureUnavailable, RouteFailureTargetChanged, RouteFailureOriginUnavailable: | |||
| 190 | + | return true | |||
| 191 | + | default: | |||
| 192 | + | return false | |||
| 193 | + | } | |||
| 194 | + | } | |||
| 195 | + | ||||
| 196 | + | const ( | |||
| 197 | + | // RouteSnapshotMaxEntries bounds one immutable publication and the amount | |||
| 198 | + | // of work a receiver performs before returning to its transport loop. | |||
| 199 | + | RouteSnapshotMaxEntries = 32 | |||
| 200 | + | RouteLabelMaxBytes = 128 | |||
| 201 | + | ) | |||
A internal/ports/routes_test.go +154 -0
| @@ -0,0 +1,154 @@ | |||
| 1 | + | package ports | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/vev/internal/domain" | |
| 7 | + | "github.com/stretchr/testify/require" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func testExactTarget() ExactSessionTarget { | |
| 11 | + | return ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1, 2, 3}, SessionName: "work"} | |
| 12 | + | } | |
| 13 | + | ||
| 14 | + | func TestExactSessionTargetCodecIsStrict(t *testing.T) { | |
| 15 | + | want := testExactTarget() | |
| 16 | + | encoded, err := MarshalExactSessionTarget(want) | |
| 17 | + | require.NoError(t, err) | |
| 18 | + | ||
| 19 | + | got, err := UnmarshalExactSessionTarget(encoded) | |
| 20 | + | require.NoError(t, err) | |
| 21 | + | require.Equal(t, want, got) | |
| 22 | + | ||
| 23 | + | for _, payload := range [][]byte{encoded[:len(encoded)-1], append(append([]byte(nil), encoded...), 0)} { | |
| 24 | + | _, err := UnmarshalExactSessionTarget(payload) | |
| 25 | + | require.Error(t, err) | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | _, err = MarshalExactSessionTarget(ExactSessionTarget{SessionName: "work"}) | |
| 29 | + | require.ErrorIs(t, err, ErrInvalidRouteWire) | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | func TestHelloExactTargetRoundTrip(t *testing.T) { | |
| 33 | + | target := testExactTarget() | |
| 34 | + | msg := Hello{ | |
| 35 | + | Version: ProtocolVersion, | |
| 36 | + | Intent: IntentAttach, | |
| 37 | + | Name: target.SessionName, | |
| 38 | + | Size: domain.Size{Cols: 80, Rows: 24}, | |
| 39 | + | ExactTarget: &target, | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | encoded := MarshalHello(msg) | |
| 43 | + | require.NotEmpty(t, encoded) | |
| 44 | + | got, err := UnmarshalHello(encoded) | |
| 45 | + | require.NoError(t, err) | |
| 46 | + | require.Equal(t, msg, got) | |
| 47 | + | ||
| 48 | + | invalid := msg | |
| 49 | + | invalid.Intent = IntentNew | |
| 50 | + | require.Error(t, ValidateHello(invalid)) | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | func TestWelcomeCarriesCommittedRouteIdentity(t *testing.T) { | |
| 54 | + | identity := &CommittedRouteIdentity{Target: testExactTarget(), Ephemeral: true} | |
| 55 | + | want := Welcome{SessionID: "daemon-session", SessionName: "work", Ephemeral: true, CommittedIdentity: identity} | |
| 56 | + | ||
| 57 | + | got, err := UnmarshalWelcome(MarshalWelcome(want)) | |
| 58 | + | require.NoError(t, err) | |
| 59 | + | require.Equal(t, want, got) | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | func TestWelcomeRejectsMismatchedCommittedIdentity(t *testing.T) { | |
| 63 | + | identity := &CommittedRouteIdentity{Target: testExactTarget()} | |
| 64 | + | payload := MarshalWelcome(Welcome{SessionID: "daemon-session", SessionName: "other", CommittedIdentity: identity}) | |
| 65 | + | ||
| 66 | + | _, err := UnmarshalWelcome(payload) | |
| 67 | + | require.ErrorIs(t, err, ErrInvalidRouteWire) | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | func TestCommittedRouteIdentityCodec(t *testing.T) { | |
| 71 | + | want := CommittedRouteIdentity{Target: testExactTarget(), Ephemeral: true} | |
| 72 | + | encoded, err := MarshalCommittedRouteIdentity(want) | |
| 73 | + | require.NoError(t, err) | |
| 74 | + | ||
| 75 | + | got, err := UnmarshalCommittedRouteIdentity(encoded) | |
| 76 | + | require.NoError(t, err) | |
| 77 | + | require.Equal(t, want, got) | |
| 78 | + | ||
| 79 | + | mutated := append([]byte(nil), encoded...) | |
| 80 | + | mutated[len(mutated)-1] = 2 | |
| 81 | + | _, err = UnmarshalCommittedRouteIdentity(mutated) | |
| 82 | + | require.Error(t, err) | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | func testRouteSnapshot() RecentRouteSnapshot { | |
| 86 | + | return RecentRouteSnapshot{ | |
| 87 | + | Generation: 9, | |
| 88 | + | Active: RouteRef{Key: 11, Generation: 3}, | |
| 89 | + | Previous: RouteRef{Key: 12, Generation: 4}, | |
| 90 | + | Home: RouteRef{Key: 11, Generation: 3}, | |
| 91 | + | Entries: []RecentRouteEntry{ | |
| 92 | + | {Key: 11, Generation: 3, Name: "work", Kind: RouteKindLocal, Reachability: RouteReachabilityReachable}, | |
| 93 | + | {Key: 12, Generation: 4, Name: "logs", HostLabel: "edge", Kind: RouteKindRemote, Attention: true, Reachability: RouteReachabilityUnknown}, | |
| 94 | + | }, | |
| 95 | + | } | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | func TestRecentRouteSnapshotCodecRoundTripAndBounds(t *testing.T) { | |
| 99 | + | want := testRouteSnapshot() | |
| 100 | + | encoded, err := MarshalRecentRouteSnapshot(want) | |
| 101 | + | require.NoError(t, err) | |
| 102 | + | ||
| 103 | + | got, err := UnmarshalRecentRouteSnapshot(encoded) | |
| 104 | + | require.NoError(t, err) | |
| 105 | + | require.Equal(t, want, got) | |
| 106 | + | ||
| 107 | + | for _, payload := range [][]byte{encoded[:len(encoded)-1], append(append([]byte(nil), encoded...), 0)} { | |
| 108 | + | _, err := UnmarshalRecentRouteSnapshot(payload) | |
| 109 | + | require.Error(t, err) | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | invalid := want | |
| 113 | + | invalid.Active = RouteRef{Key: 99, Generation: 99} | |
| 114 | + | _, err = MarshalRecentRouteSnapshot(invalid) | |
| 115 | + | require.ErrorIs(t, err, ErrInvalidRouteWire) | |
| 116 | + | ||
| 117 | + | invalid = want | |
| 118 | + | invalid.Entries = append(invalid.Entries, invalid.Entries[0]) | |
| 119 | + | _, err = MarshalRecentRouteSnapshot(invalid) | |
| 120 | + | require.ErrorIs(t, err, ErrInvalidRouteWire) | |
| 121 | + | ||
| 122 | + | invalid = want | |
| 123 | + | invalid.Entries = make([]RecentRouteEntry, RouteSnapshotMaxEntries+1) | |
| 124 | + | for i := range invalid.Entries { | |
| 125 | + | invalid.Entries[i] = RecentRouteEntry{Key: uint64(i + 1), Generation: uint64(i + 1), Name: "x", Kind: RouteKindLocal} | |
| 126 | + | } | |
| 127 | + | _, err = MarshalRecentRouteSnapshot(invalid) | |
| 128 | + | require.ErrorIs(t, err, ErrInvalidRouteWire) | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | func TestRouteActionAndFailureCodecsAreBounded(t *testing.T) { | |
| 132 | + | action := RouteNavigationAction{Key: 7, Generation: 8} | |
| 133 | + | encoded, err := MarshalRouteNavigationAction(action) | |
| 134 | + | require.NoError(t, err) | |
| 135 | + | got, err := UnmarshalRouteNavigationAction(encoded) | |
| 136 | + | require.NoError(t, err) | |
| 137 | + | require.Equal(t, action, got) | |
| 138 | + | ||
| 139 | + | failure := RouteNavigationFailure{Key: 7, Generation: 8, Code: RouteFailureStaleSelection} | |
| 140 | + | encoded, err = MarshalRouteNavigationFailure(failure) | |
| 141 | + | require.NoError(t, err) | |
| 142 | + | gotFailure, err := UnmarshalRouteNavigationFailure(encoded) | |
| 143 | + | require.NoError(t, err) | |
| 144 | + | require.Equal(t, failure, gotFailure) | |
| 145 | + | ||
| 146 | + | for _, invalid := range []RouteNavigationAction{{}, {Key: 1}, {Generation: 1}} { | |
| 147 | + | _, err := MarshalRouteNavigationAction(invalid) | |
| 148 | + | require.Error(t, err) | |
| 149 | + | } | |
| 150 | + | for _, invalid := range []RouteNavigationFailure{{}, {Key: 1, Generation: 1}, {Key: 1, Generation: 1, Code: 255}} { | |
| 151 | + | _, err := MarshalRouteNavigationFailure(invalid) | |
| 152 | + | require.Error(t, err) | |
| 153 | + | } | |
| 154 | + | } | |
| @@ -0,0 +1,154 @@ | |||||
| 1 | + | package ports | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/vev/internal/domain" | |||
| 7 | + | "github.com/stretchr/testify/require" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func testExactTarget() ExactSessionTarget { | |||
| 11 | + | return ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1, 2, 3}, SessionName: "work"} | |||
| 12 | + | } | |||
| 13 | + | ||||
| 14 | + | func TestExactSessionTargetCodecIsStrict(t *testing.T) { | |||
| 15 | + | want := testExactTarget() | |||
| 16 | + | encoded, err := MarshalExactSessionTarget(want) | |||
| 17 | + | require.NoError(t, err) | |||
| 18 | + | ||||
| 19 | + | got, err := UnmarshalExactSessionTarget(encoded) | |||
| 20 | + | require.NoError(t, err) | |||
| 21 | + | require.Equal(t, want, got) | |||
| 22 | + | ||||
| 23 | + | for _, payload := range [][]byte{encoded[:len(encoded)-1], append(append([]byte(nil), encoded...), 0)} { | |||
| 24 | + | _, err := UnmarshalExactSessionTarget(payload) | |||
| 25 | + | require.Error(t, err) | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | _, err = MarshalExactSessionTarget(ExactSessionTarget{SessionName: "work"}) | |||
| 29 | + | require.ErrorIs(t, err, ErrInvalidRouteWire) | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | func TestHelloExactTargetRoundTrip(t *testing.T) { | |||
| 33 | + | target := testExactTarget() | |||
| 34 | + | msg := Hello{ | |||
| 35 | + | Version: ProtocolVersion, | |||
| 36 | + | Intent: IntentAttach, | |||
| 37 | + | Name: target.SessionName, | |||
| 38 | + | Size: domain.Size{Cols: 80, Rows: 24}, | |||
| 39 | + | ExactTarget: &target, | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | encoded := MarshalHello(msg) | |||
| 43 | + | require.NotEmpty(t, encoded) | |||
| 44 | + | got, err := UnmarshalHello(encoded) | |||
| 45 | + | require.NoError(t, err) | |||
| 46 | + | require.Equal(t, msg, got) | |||
| 47 | + | ||||
| 48 | + | invalid := msg | |||
| 49 | + | invalid.Intent = IntentNew | |||
| 50 | + | require.Error(t, ValidateHello(invalid)) | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | func TestWelcomeCarriesCommittedRouteIdentity(t *testing.T) { | |||
| 54 | + | identity := &CommittedRouteIdentity{Target: testExactTarget(), Ephemeral: true} | |||
| 55 | + | want := Welcome{SessionID: "daemon-session", SessionName: "work", Ephemeral: true, CommittedIdentity: identity} | |||
| 56 | + | ||||
| 57 | + | got, err := UnmarshalWelcome(MarshalWelcome(want)) | |||
| 58 | + | require.NoError(t, err) | |||
| 59 | + | require.Equal(t, want, got) | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | func TestWelcomeRejectsMismatchedCommittedIdentity(t *testing.T) { | |||
| 63 | + | identity := &CommittedRouteIdentity{Target: testExactTarget()} | |||
| 64 | + | payload := MarshalWelcome(Welcome{SessionID: "daemon-session", SessionName: "other", CommittedIdentity: identity}) | |||
| 65 | + | ||||
| 66 | + | _, err := UnmarshalWelcome(payload) | |||
| 67 | + | require.ErrorIs(t, err, ErrInvalidRouteWire) | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | func TestCommittedRouteIdentityCodec(t *testing.T) { | |||
| 71 | + | want := CommittedRouteIdentity{Target: testExactTarget(), Ephemeral: true} | |||
| 72 | + | encoded, err := MarshalCommittedRouteIdentity(want) | |||
| 73 | + | require.NoError(t, err) | |||
| 74 | + | ||||
| 75 | + | got, err := UnmarshalCommittedRouteIdentity(encoded) | |||
| 76 | + | require.NoError(t, err) | |||
| 77 | + | require.Equal(t, want, got) | |||
| 78 | + | ||||
| 79 | + | mutated := append([]byte(nil), encoded...) | |||
| 80 | + | mutated[len(mutated)-1] = 2 | |||
| 81 | + | _, err = UnmarshalCommittedRouteIdentity(mutated) | |||
| 82 | + | require.Error(t, err) | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | func testRouteSnapshot() RecentRouteSnapshot { | |||
| 86 | + | return RecentRouteSnapshot{ | |||
| 87 | + | Generation: 9, | |||
| 88 | + | Active: RouteRef{Key: 11, Generation: 3}, | |||
| 89 | + | Previous: RouteRef{Key: 12, Generation: 4}, | |||
| 90 | + | Home: RouteRef{Key: 11, Generation: 3}, | |||
| 91 | + | Entries: []RecentRouteEntry{ | |||
| 92 | + | {Key: 11, Generation: 3, Name: "work", Kind: RouteKindLocal, Reachability: RouteReachabilityReachable}, | |||
| 93 | + | {Key: 12, Generation: 4, Name: "logs", HostLabel: "edge", Kind: RouteKindRemote, Attention: true, Reachability: RouteReachabilityUnknown}, | |||
| 94 | + | }, | |||
| 95 | + | } | |||
| 96 | + | } | |||
| 97 | + | ||||
| 98 | + | func TestRecentRouteSnapshotCodecRoundTripAndBounds(t *testing.T) { | |||
| 99 | + | want := testRouteSnapshot() | |||
| 100 | + | encoded, err := MarshalRecentRouteSnapshot(want) | |||
| 101 | + | require.NoError(t, err) | |||
| 102 | + | ||||
| 103 | + | got, err := UnmarshalRecentRouteSnapshot(encoded) | |||
| 104 | + | require.NoError(t, err) | |||
| 105 | + | require.Equal(t, want, got) | |||
| 106 | + | ||||
| 107 | + | for _, payload := range [][]byte{encoded[:len(encoded)-1], append(append([]byte(nil), encoded...), 0)} { | |||
| 108 | + | _, err := UnmarshalRecentRouteSnapshot(payload) | |||
| 109 | + | require.Error(t, err) | |||
| 110 | + | } | |||
| 111 | + | ||||
| 112 | + | invalid := want | |||
| 113 | + | invalid.Active = RouteRef{Key: 99, Generation: 99} | |||
| 114 | + | _, err = MarshalRecentRouteSnapshot(invalid) | |||
| 115 | + | require.ErrorIs(t, err, ErrInvalidRouteWire) | |||
| 116 | + | ||||
| 117 | + | invalid = want | |||
| 118 | + | invalid.Entries = append(invalid.Entries, invalid.Entries[0]) | |||
| 119 | + | _, err = MarshalRecentRouteSnapshot(invalid) | |||
| 120 | + | require.ErrorIs(t, err, ErrInvalidRouteWire) | |||
| 121 | + | ||||
| 122 | + | invalid = want | |||
| 123 | + | invalid.Entries = make([]RecentRouteEntry, RouteSnapshotMaxEntries+1) | |||
| 124 | + | for i := range invalid.Entries { | |||
| 125 | + | invalid.Entries[i] = RecentRouteEntry{Key: uint64(i + 1), Generation: uint64(i + 1), Name: "x", Kind: RouteKindLocal} | |||
| 126 | + | } | |||
| 127 | + | _, err = MarshalRecentRouteSnapshot(invalid) | |||
| 128 | + | require.ErrorIs(t, err, ErrInvalidRouteWire) | |||
| 129 | + | } | |||
| 130 | + | ||||
| 131 | + | func TestRouteActionAndFailureCodecsAreBounded(t *testing.T) { | |||
| 132 | + | action := RouteNavigationAction{Key: 7, Generation: 8} | |||
| 133 | + | encoded, err := MarshalRouteNavigationAction(action) | |||
| 134 | + | require.NoError(t, err) | |||
| 135 | + | got, err := UnmarshalRouteNavigationAction(encoded) | |||
| 136 | + | require.NoError(t, err) | |||
| 137 | + | require.Equal(t, action, got) | |||
| 138 | + | ||||
| 139 | + | failure := RouteNavigationFailure{Key: 7, Generation: 8, Code: RouteFailureStaleSelection} | |||
| 140 | + | encoded, err = MarshalRouteNavigationFailure(failure) | |||
| 141 | + | require.NoError(t, err) | |||
| 142 | + | gotFailure, err := UnmarshalRouteNavigationFailure(encoded) | |||
| 143 | + | require.NoError(t, err) | |||
| 144 | + | require.Equal(t, failure, gotFailure) | |||
| 145 | + | ||||
| 146 | + | for _, invalid := range []RouteNavigationAction{{}, {Key: 1}, {Generation: 1}} { | |||
| 147 | + | _, err := MarshalRouteNavigationAction(invalid) | |||
| 148 | + | require.Error(t, err) | |||
| 149 | + | } | |||
| 150 | + | for _, invalid := range []RouteNavigationFailure{{}, {Key: 1, Generation: 1}, {Key: 1, Generation: 1, Code: 255}} { | |||
| 151 | + | _, err := MarshalRouteNavigationFailure(invalid) | |||
| 152 | + | require.Error(t, err) | |||
| 153 | + | } | |||
| 154 | + | } | |||
A internal/ports/routes_wire.go +438 -0
| @@ -0,0 +1,438 @@ | |||
| 1 | + | package ports | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | "fmt" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | var ErrInvalidRouteWire = errors.New("invalid route wire message") | |
| 9 | + | ||
| 10 | + | func validateRouteString(value, field string, allowEmpty bool) error { | |
| 11 | + | if !allowEmpty && value == "" { | |
| 12 | + | return fmt.Errorf("%s is empty", field) | |
| 13 | + | } | |
| 14 | + | if len(value) > RouteLabelMaxBytes { | |
| 15 | + | return fmt.Errorf("%s is too long", field) | |
| 16 | + | } | |
| 17 | + | return nil | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | func marshalExactSessionTarget(w *payloadWriter, target ExactSessionTarget) { | |
| 21 | + | w.putBytes(target.LifecycleID[:]) | |
| 22 | + | w.putString(target.SessionName) | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | func marshalExactTargetSection(w *payloadWriter, target *ExactSessionTarget) { | |
| 26 | + | w.putBool(target != nil) | |
| 27 | + | if target != nil { | |
| 28 | + | marshalExactSessionTarget(w, *target) | |
| 29 | + | } | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | func unmarshalExactTargetSection(r *payloadReader) (*ExactSessionTarget, error) { | |
| 33 | + | present, err := r.getBool() | |
| 34 | + | if err != nil { | |
| 35 | + | return nil, err | |
| 36 | + | } | |
| 37 | + | if !present { | |
| 38 | + | return nil, nil | |
| 39 | + | } | |
| 40 | + | target, err := unmarshalExactSessionTarget(r) | |
| 41 | + | if err != nil { | |
| 42 | + | return nil, err | |
| 43 | + | } | |
| 44 | + | return &target, nil | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | func skipExactTargetSection(r *payloadReader) error { | |
| 48 | + | present, err := r.getBool() | |
| 49 | + | if err != nil { | |
| 50 | + | return err | |
| 51 | + | } | |
| 52 | + | if !present { | |
| 53 | + | return nil | |
| 54 | + | } | |
| 55 | + | if _, err := unmarshalExactSessionTarget(r); err != nil { | |
| 56 | + | return err | |
| 57 | + | } | |
| 58 | + | return nil | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | func unmarshalExactSessionTarget(r *payloadReader) (ExactSessionTarget, error) { | |
| 62 | + | var target ExactSessionTarget | |
| 63 | + | lifecycle, err := r.getBytes(len(target.LifecycleID)) | |
| 64 | + | if err != nil { | |
| 65 | + | return ExactSessionTarget{}, err | |
| 66 | + | } | |
| 67 | + | copy(target.LifecycleID[:], lifecycle) | |
| 68 | + | if target.SessionName, err = r.getString(); err != nil { | |
| 69 | + | return ExactSessionTarget{}, err | |
| 70 | + | } | |
| 71 | + | if err := target.Validate(); err != nil { | |
| 72 | + | return ExactSessionTarget{}, fmt.Errorf("%w: exact target: %v", ErrInvalidRouteWire, err) | |
| 73 | + | } | |
| 74 | + | return target, nil | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | // MarshalExactSessionTarget encodes the exact lifecycle/name pair used by | |
| 78 | + | // Hello and route-transition tests. | |
| 79 | + | func MarshalExactSessionTarget(target ExactSessionTarget) ([]byte, error) { | |
| 80 | + | if err := target.Validate(); err != nil { | |
| 81 | + | return nil, fmt.Errorf("%w: exact target: %v", ErrInvalidRouteWire, err) | |
| 82 | + | } | |
| 83 | + | w := payloadWriter{} | |
| 84 | + | marshalExactSessionTarget(&w, target) | |
| 85 | + | return w.b, nil | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | // UnmarshalExactSessionTarget decodes one exact lifecycle/name pair and rejects | |
| 89 | + | // truncation, invalid identity, and trailing bytes. | |
| 90 | + | func UnmarshalExactSessionTarget(b []byte) (ExactSessionTarget, error) { | |
| 91 | + | r := payloadReader{b: b} | |
| 92 | + | target, err := unmarshalExactSessionTarget(&r) | |
| 93 | + | if err != nil { | |
| 94 | + | return ExactSessionTarget{}, err | |
| 95 | + | } | |
| 96 | + | if err := r.done(); err != nil { | |
| 97 | + | return ExactSessionTarget{}, err | |
| 98 | + | } | |
| 99 | + | return target, nil | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | func validateCommittedRouteIdentity(identity CommittedRouteIdentity) error { | |
| 103 | + | if err := identity.Validate(); err != nil { | |
| 104 | + | return fmt.Errorf("%w: committed identity: %v", ErrInvalidRouteWire, err) | |
| 105 | + | } | |
| 106 | + | return nil | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | // MarshalCommittedRouteIdentity encodes the daemon's committed exact session | |
| 110 | + | // identity without exposing transport or client-ledger details. | |
| 111 | + | func MarshalCommittedRouteIdentity(identity CommittedRouteIdentity) ([]byte, error) { | |
| 112 | + | if err := validateCommittedRouteIdentity(identity); err != nil { | |
| 113 | + | return nil, err | |
| 114 | + | } | |
| 115 | + | w := payloadWriter{} | |
| 116 | + | marshalExactSessionTarget(&w, identity.Target) | |
| 117 | + | w.putBool(identity.Ephemeral) | |
| 118 | + | return w.b, nil | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | func marshalCommittedIdentitySection(w *payloadWriter, identity *CommittedRouteIdentity) { | |
| 122 | + | w.putBool(identity != nil) | |
| 123 | + | if identity != nil { | |
| 124 | + | marshalExactSessionTarget(w, identity.Target) | |
| 125 | + | w.putBool(identity.Ephemeral) | |
| 126 | + | } | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | func unmarshalCommittedIdentitySection(r *payloadReader) (*CommittedRouteIdentity, error) { | |
| 130 | + | present, err := r.getBool() | |
| 131 | + | if err != nil { | |
| 132 | + | return nil, err | |
| 133 | + | } | |
| 134 | + | if !present { | |
| 135 | + | return nil, nil | |
| 136 | + | } | |
| 137 | + | target, err := unmarshalExactSessionTarget(r) | |
| 138 | + | if err != nil { | |
| 139 | + | return nil, err | |
| 140 | + | } | |
| 141 | + | ephemeral, err := r.getBool() | |
| 142 | + | if err != nil { | |
| 143 | + | return nil, err | |
| 144 | + | } | |
| 145 | + | identity := &CommittedRouteIdentity{Target: target, Ephemeral: ephemeral} | |
| 146 | + | if err := validateCommittedRouteIdentity(*identity); err != nil { | |
| 147 | + | return nil, err | |
| 148 | + | } | |
| 149 | + | return identity, nil | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | func UnmarshalCommittedRouteIdentity(b []byte) (CommittedRouteIdentity, error) { | |
| 153 | + | r := payloadReader{b: b} | |
| 154 | + | target, err := unmarshalExactSessionTarget(&r) | |
| 155 | + | if err != nil { | |
| 156 | + | return CommittedRouteIdentity{}, err | |
| 157 | + | } | |
| 158 | + | ephemeral, err := r.getBool() | |
| 159 | + | if err != nil { | |
| 160 | + | return CommittedRouteIdentity{}, err | |
| 161 | + | } | |
| 162 | + | if err := r.done(); err != nil { | |
| 163 | + | return CommittedRouteIdentity{}, err | |
| 164 | + | } | |
| 165 | + | identity := CommittedRouteIdentity{Target: target, Ephemeral: ephemeral} | |
| 166 | + | if err := validateCommittedRouteIdentity(identity); err != nil { | |
| 167 | + | return CommittedRouteIdentity{}, err | |
| 168 | + | } | |
| 169 | + | return identity, nil | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | func validateRouteRef(ref RouteRef) error { | |
| 173 | + | if err := ref.Validate(); err != nil { | |
| 174 | + | return fmt.Errorf("%w: route reference: %v", ErrInvalidRouteWire, err) | |
| 175 | + | } | |
| 176 | + | return nil | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | func marshalRouteRef(w *payloadWriter, ref RouteRef) { | |
| 180 | + | w.putUint64(ref.Key) | |
| 181 | + | w.putUint64(ref.Generation) | |
| 182 | + | } | |
| 183 | + | ||
| 184 | + | func unmarshalRouteRef(r *payloadReader) (RouteRef, error) { | |
| 185 | + | key, err := r.getUint64() | |
| 186 | + | if err != nil { | |
| 187 | + | return RouteRef{}, err | |
| 188 | + | } | |
| 189 | + | generation, err := r.getUint64() | |
| 190 | + | if err != nil { | |
| 191 | + | return RouteRef{}, err | |
| 192 | + | } | |
| 193 | + | ref := RouteRef{Key: key, Generation: generation} | |
| 194 | + | if err := validateRouteRef(ref); err != nil { | |
| 195 | + | return RouteRef{}, err | |
| 196 | + | } | |
| 197 | + | return ref, nil | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | func validateRecentRouteEntry(entry RecentRouteEntry) error { | |
| 201 | + | if entry.Key == 0 || entry.Generation == 0 { | |
| 202 | + | return fmt.Errorf("%w: route entry identity is zero", ErrInvalidRouteWire) | |
| 203 | + | } | |
| 204 | + | if err := validateRouteString(entry.Name, "route name", false); err != nil { | |
| 205 | + | return fmt.Errorf("%w: %v", ErrInvalidRouteWire, err) | |
| 206 | + | } | |
| 207 | + | if err := validateRouteString(entry.HostLabel, "route host label", true); err != nil { | |
| 208 | + | return fmt.Errorf("%w: %v", ErrInvalidRouteWire, err) | |
| 209 | + | } | |
| 210 | + | if !entry.Kind.valid() { | |
| 211 | + | return fmt.Errorf("%w: invalid route kind", ErrInvalidRouteWire) | |
| 212 | + | } | |
| 213 | + | if !entry.Reachability.valid() { | |
| 214 | + | return fmt.Errorf("%w: invalid route reachability", ErrInvalidRouteWire) | |
| 215 | + | } | |
| 216 | + | return nil | |
| 217 | + | } | |
| 218 | + | ||
| 219 | + | func validateRecentRouteSnapshot(snapshot RecentRouteSnapshot) error { | |
| 220 | + | if len(snapshot.Entries) > RouteSnapshotMaxEntries { | |
| 221 | + | return fmt.Errorf("%w: too many route entries", ErrInvalidRouteWire) | |
| 222 | + | } | |
| 223 | + | if snapshot.Generation == 0 && len(snapshot.Entries) != 0 { | |
| 224 | + | return fmt.Errorf("%w: non-empty snapshot has zero generation", ErrInvalidRouteWire) | |
| 225 | + | } | |
| 226 | + | if err := validateRouteRef(snapshot.Active); err != nil { | |
| 227 | + | return err | |
| 228 | + | } | |
| 229 | + | if err := validateRouteRef(snapshot.Previous); err != nil { | |
| 230 | + | return err | |
| 231 | + | } | |
| 232 | + | if err := validateRouteRef(snapshot.Home); err != nil { | |
| 233 | + | return err | |
| 234 | + | } | |
| 235 | + | if !snapshot.Active.empty() && snapshot.Active == snapshot.Previous { | |
| 236 | + | return fmt.Errorf("%w: active and previous routes are identical", ErrInvalidRouteWire) | |
| 237 | + | } | |
| 238 | + | refs := make(map[RouteRef]struct{}, len(snapshot.Entries)) | |
| 239 | + | for _, entry := range snapshot.Entries { | |
| 240 | + | if err := validateRecentRouteEntry(entry); err != nil { | |
| 241 | + | return err | |
| 242 | + | } | |
| 243 | + | ref := RouteRef{Key: entry.Key, Generation: entry.Generation} | |
| 244 | + | if _, exists := refs[ref]; exists { | |
| 245 | + | return fmt.Errorf("%w: duplicate route entry", ErrInvalidRouteWire) | |
| 246 | + | } | |
| 247 | + | refs[ref] = struct{}{} | |
| 248 | + | } | |
| 249 | + | for _, ref := range []RouteRef{snapshot.Active, snapshot.Previous, snapshot.Home} { | |
| 250 | + | if ref.empty() { | |
| 251 | + | continue | |
| 252 | + | } | |
| 253 | + | if _, exists := refs[ref]; !exists { | |
| 254 | + | return fmt.Errorf("%w: route reference is absent from snapshot", ErrInvalidRouteWire) | |
| 255 | + | } | |
| 256 | + | } | |
| 257 | + | return nil | |
| 258 | + | } | |
| 259 | + | ||
| 260 | + | // MarshalRecentRouteSnapshot encodes a complete bounded client route view. | |
| 261 | + | func MarshalRecentRouteSnapshot(snapshot RecentRouteSnapshot) ([]byte, error) { | |
| 262 | + | if err := validateRecentRouteSnapshot(snapshot); err != nil { | |
| 263 | + | return nil, err | |
| 264 | + | } | |
| 265 | + | w := payloadWriter{} | |
| 266 | + | w.putUint64(snapshot.Generation) | |
| 267 | + | marshalRouteRef(&w, snapshot.Active) | |
| 268 | + | marshalRouteRef(&w, snapshot.Previous) | |
| 269 | + | marshalRouteRef(&w, snapshot.Home) | |
| 270 | + | w.putUint8(uint8(len(snapshot.Entries))) | |
| 271 | + | for _, entry := range snapshot.Entries { | |
| 272 | + | w.putUint64(entry.Key) | |
| 273 | + | w.putUint64(entry.Generation) | |
| 274 | + | w.putString(entry.Name) | |
| 275 | + | w.putString(entry.HostLabel) | |
| 276 | + | w.putUint8(uint8(entry.Kind)) | |
| 277 | + | w.putBool(entry.Ephemeral) | |
| 278 | + | w.putBool(entry.Attention) | |
| 279 | + | w.putUint8(uint8(entry.Reachability)) | |
| 280 | + | } | |
| 281 | + | if len(w.b) > MaxFrameLen-1 { | |
| 282 | + | return nil, fmt.Errorf("%w: route snapshot is too large", ErrInvalidRouteWire) | |
| 283 | + | } | |
| 284 | + | return w.b, nil | |
| 285 | + | } | |
| 286 | + | ||
| 287 | + | // UnmarshalRecentRouteSnapshot decodes and strictly validates one complete | |
| 288 | + | // immutable snapshot, including every referenced active/previous/home entry. | |
| 289 | + | func UnmarshalRecentRouteSnapshot(b []byte) (RecentRouteSnapshot, error) { | |
| 290 | + | if len(b) > MaxFrameLen-1 { | |
| 291 | + | return RecentRouteSnapshot{}, ErrInvalidRouteWire | |
| 292 | + | } | |
| 293 | + | r := payloadReader{b: b} | |
| 294 | + | var snapshot RecentRouteSnapshot | |
| 295 | + | var err error | |
| 296 | + | if snapshot.Generation, err = r.getUint64(); err != nil { | |
| 297 | + | return RecentRouteSnapshot{}, err | |
| 298 | + | } | |
| 299 | + | if snapshot.Active, err = unmarshalRouteRef(&r); err != nil { | |
| 300 | + | return RecentRouteSnapshot{}, err | |
| 301 | + | } | |
| 302 | + | if snapshot.Previous, err = unmarshalRouteRef(&r); err != nil { | |
| 303 | + | return RecentRouteSnapshot{}, err | |
| 304 | + | } | |
| 305 | + | if snapshot.Home, err = unmarshalRouteRef(&r); err != nil { | |
| 306 | + | return RecentRouteSnapshot{}, err | |
| 307 | + | } | |
| 308 | + | count, err := r.getUint8() | |
| 309 | + | if err != nil { | |
| 310 | + | return RecentRouteSnapshot{}, err | |
| 311 | + | } | |
| 312 | + | if int(count) > RouteSnapshotMaxEntries { | |
| 313 | + | return RecentRouteSnapshot{}, fmt.Errorf("%w: too many route entries", ErrInvalidRouteWire) | |
| 314 | + | } | |
| 315 | + | if count != 0 { | |
| 316 | + | snapshot.Entries = make([]RecentRouteEntry, 0, int(count)) | |
| 317 | + | } | |
| 318 | + | for range int(count) { | |
| 319 | + | var entry RecentRouteEntry | |
| 320 | + | if entry.Key, err = r.getUint64(); err != nil { | |
| 321 | + | return RecentRouteSnapshot{}, err | |
| 322 | + | } | |
| 323 | + | if entry.Generation, err = r.getUint64(); err != nil { | |
| 324 | + | return RecentRouteSnapshot{}, err | |
| 325 | + | } | |
| 326 | + | if entry.Name, err = r.getString(); err != nil { | |
| 327 | + | return RecentRouteSnapshot{}, err | |
| 328 | + | } | |
| 329 | + | if entry.HostLabel, err = r.getString(); err != nil { | |
| 330 | + | return RecentRouteSnapshot{}, err | |
| 331 | + | } | |
| 332 | + | kind, err := r.getUint8() | |
| 333 | + | if err != nil { | |
| 334 | + | return RecentRouteSnapshot{}, err | |
| 335 | + | } | |
| 336 | + | entry.Kind = RouteKind(kind) | |
| 337 | + | if entry.Ephemeral, err = r.getBool(); err != nil { | |
| 338 | + | return RecentRouteSnapshot{}, err | |
| 339 | + | } | |
| 340 | + | if entry.Attention, err = r.getBool(); err != nil { | |
| 341 | + | return RecentRouteSnapshot{}, err | |
| 342 | + | } | |
| 343 | + | reachability, err := r.getUint8() | |
| 344 | + | if err != nil { | |
| 345 | + | return RecentRouteSnapshot{}, err | |
| 346 | + | } | |
| 347 | + | entry.Reachability = RouteReachability(reachability) | |
| 348 | + | snapshot.Entries = append(snapshot.Entries, entry) | |
| 349 | + | } | |
| 350 | + | if err := r.done(); err != nil { | |
| 351 | + | return RecentRouteSnapshot{}, err | |
| 352 | + | } | |
| 353 | + | if err := validateRecentRouteSnapshot(snapshot); err != nil { | |
| 354 | + | return RecentRouteSnapshot{}, err | |
| 355 | + | } | |
| 356 | + | return snapshot, nil | |
| 357 | + | } | |
| 358 | + | ||
| 359 | + | func validateRouteNavigationAction(action RouteNavigationAction) error { | |
| 360 | + | if action.Key == 0 || action.Generation == 0 { | |
| 361 | + | return fmt.Errorf("%w: navigation action identity is zero", ErrInvalidRouteWire) | |
| 362 | + | } | |
| 363 | + | return nil | |
| 364 | + | } | |
| 365 | + | ||
| 366 | + | func MarshalRouteNavigationAction(action RouteNavigationAction) ([]byte, error) { | |
| 367 | + | if err := validateRouteNavigationAction(action); err != nil { | |
| 368 | + | return nil, err | |
| 369 | + | } | |
| 370 | + | w := payloadWriter{} | |
| 371 | + | w.putUint64(action.Key) | |
| 372 | + | w.putUint64(action.Generation) | |
| 373 | + | return w.b, nil | |
| 374 | + | } | |
| 375 | + | ||
| 376 | + | func UnmarshalRouteNavigationAction(b []byte) (RouteNavigationAction, error) { | |
| 377 | + | r := payloadReader{b: b} | |
| 378 | + | var action RouteNavigationAction | |
| 379 | + | var err error | |
| 380 | + | if action.Key, err = r.getUint64(); err != nil { | |
| 381 | + | return RouteNavigationAction{}, err | |
| 382 | + | } | |
| 383 | + | if action.Generation, err = r.getUint64(); err != nil { | |
| 384 | + | return RouteNavigationAction{}, err | |
| 385 | + | } | |
| 386 | + | if err := r.done(); err != nil { | |
| 387 | + | return RouteNavigationAction{}, err | |
| 388 | + | } | |
| 389 | + | if err := validateRouteNavigationAction(action); err != nil { | |
| 390 | + | return RouteNavigationAction{}, err | |
| 391 | + | } | |
| 392 | + | return action, nil | |
| 393 | + | } | |
| 394 | + | ||
| 395 | + | func validateRouteNavigationFailure(failure RouteNavigationFailure) error { | |
| 396 | + | if failure.Key == 0 || failure.Generation == 0 { | |
| 397 | + | return fmt.Errorf("%w: failure identity is zero", ErrInvalidRouteWire) | |
| 398 | + | } | |
| 399 | + | if !failure.Code.valid() { | |
| 400 | + | return fmt.Errorf("%w: invalid failure code", ErrInvalidRouteWire) | |
| 401 | + | } | |
| 402 | + | return nil | |
| 403 | + | } | |
| 404 | + | ||
| 405 | + | func MarshalRouteNavigationFailure(failure RouteNavigationFailure) ([]byte, error) { | |
| 406 | + | if err := validateRouteNavigationFailure(failure); err != nil { | |
| 407 | + | return nil, err | |
| 408 | + | } | |
| 409 | + | w := payloadWriter{} | |
| 410 | + | w.putUint64(failure.Key) | |
| 411 | + | w.putUint64(failure.Generation) | |
| 412 | + | w.putUint8(uint8(failure.Code)) | |
| 413 | + | return w.b, nil | |
| 414 | + | } | |
| 415 | + | ||
| 416 | + | func UnmarshalRouteNavigationFailure(b []byte) (RouteNavigationFailure, error) { | |
| 417 | + | r := payloadReader{b: b} | |
| 418 | + | var failure RouteNavigationFailure | |
| 419 | + | var err error | |
| 420 | + | if failure.Key, err = r.getUint64(); err != nil { | |
| 421 | + | return RouteNavigationFailure{}, err | |
| 422 | + | } | |
| 423 | + | if failure.Generation, err = r.getUint64(); err != nil { | |
| 424 | + | return RouteNavigationFailure{}, err | |
| 425 | + | } | |
| 426 | + | code, err := r.getUint8() | |
| 427 | + | if err != nil { | |
| 428 | + | return RouteNavigationFailure{}, err | |
| 429 | + | } | |
| 430 | + | failure.Code = RouteFailureCode(code) | |
| 431 | + | if err := r.done(); err != nil { | |
| 432 | + | return RouteNavigationFailure{}, err | |
| 433 | + | } | |
| 434 | + | if err := validateRouteNavigationFailure(failure); err != nil { | |
| 435 | + | return RouteNavigationFailure{}, err | |
| 436 | + | } | |
| 437 | + | return failure, nil | |
| 438 | + | } | |
| @@ -0,0 +1,438 @@ | |||||
| 1 | + | package ports | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "errors" | |||
| 5 | + | "fmt" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | var ErrInvalidRouteWire = errors.New("invalid route wire message") | |||
| 9 | + | ||||
| 10 | + | func validateRouteString(value, field string, allowEmpty bool) error { | |||
| 11 | + | if !allowEmpty && value == "" { | |||
| 12 | + | return fmt.Errorf("%s is empty", field) | |||
| 13 | + | } | |||
| 14 | + | if len(value) > RouteLabelMaxBytes { | |||
| 15 | + | return fmt.Errorf("%s is too long", field) | |||
| 16 | + | } | |||
| 17 | + | return nil | |||
| 18 | + | } | |||
| 19 | + | ||||
| 20 | + | func marshalExactSessionTarget(w *payloadWriter, target ExactSessionTarget) { | |||
| 21 | + | w.putBytes(target.LifecycleID[:]) | |||
| 22 | + | w.putString(target.SessionName) | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | func marshalExactTargetSection(w *payloadWriter, target *ExactSessionTarget) { | |||
| 26 | + | w.putBool(target != nil) | |||
| 27 | + | if target != nil { | |||
| 28 | + | marshalExactSessionTarget(w, *target) | |||
| 29 | + | } | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | func unmarshalExactTargetSection(r *payloadReader) (*ExactSessionTarget, error) { | |||
| 33 | + | present, err := r.getBool() | |||
| 34 | + | if err != nil { | |||
| 35 | + | return nil, err | |||
| 36 | + | } | |||
| 37 | + | if !present { | |||
| 38 | + | return nil, nil | |||
| 39 | + | } | |||
| 40 | + | target, err := unmarshalExactSessionTarget(r) | |||
| 41 | + | if err != nil { | |||
| 42 | + | return nil, err | |||
| 43 | + | } | |||
| 44 | + | return &target, nil | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | func skipExactTargetSection(r *payloadReader) error { | |||
| 48 | + | present, err := r.getBool() | |||
| 49 | + | if err != nil { | |||
| 50 | + | return err | |||
| 51 | + | } | |||
| 52 | + | if !present { | |||
| 53 | + | return nil | |||
| 54 | + | } | |||
| 55 | + | if _, err := unmarshalExactSessionTarget(r); err != nil { | |||
| 56 | + | return err | |||
| 57 | + | } | |||
| 58 | + | return nil | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | func unmarshalExactSessionTarget(r *payloadReader) (ExactSessionTarget, error) { | |||
| 62 | + | var target ExactSessionTarget | |||
| 63 | + | lifecycle, err := r.getBytes(len(target.LifecycleID)) | |||
| 64 | + | if err != nil { | |||
| 65 | + | return ExactSessionTarget{}, err | |||
| 66 | + | } | |||
| 67 | + | copy(target.LifecycleID[:], lifecycle) | |||
| 68 | + | if target.SessionName, err = r.getString(); err != nil { | |||
| 69 | + | return ExactSessionTarget{}, err | |||
| 70 | + | } | |||
| 71 | + | if err := target.Validate(); err != nil { | |||
| 72 | + | return ExactSessionTarget{}, fmt.Errorf("%w: exact target: %v", ErrInvalidRouteWire, err) | |||
| 73 | + | } | |||
| 74 | + | return target, nil | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | // MarshalExactSessionTarget encodes the exact lifecycle/name pair used by | |||
| 78 | + | // Hello and route-transition tests. | |||
| 79 | + | func MarshalExactSessionTarget(target ExactSessionTarget) ([]byte, error) { | |||
| 80 | + | if err := target.Validate(); err != nil { | |||
| 81 | + | return nil, fmt.Errorf("%w: exact target: %v", ErrInvalidRouteWire, err) | |||
| 82 | + | } | |||
| 83 | + | w := payloadWriter{} | |||
| 84 | + | marshalExactSessionTarget(&w, target) | |||
| 85 | + | return w.b, nil | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | // UnmarshalExactSessionTarget decodes one exact lifecycle/name pair and rejects | |||
| 89 | + | // truncation, invalid identity, and trailing bytes. | |||
| 90 | + | func UnmarshalExactSessionTarget(b []byte) (ExactSessionTarget, error) { | |||
| 91 | + | r := payloadReader{b: b} | |||
| 92 | + | target, err := unmarshalExactSessionTarget(&r) | |||
| 93 | + | if err != nil { | |||
| 94 | + | return ExactSessionTarget{}, err | |||
| 95 | + | } | |||
| 96 | + | if err := r.done(); err != nil { | |||
| 97 | + | return ExactSessionTarget{}, err | |||
| 98 | + | } | |||
| 99 | + | return target, nil | |||
| 100 | + | } | |||
| 101 | + | ||||
| 102 | + | func validateCommittedRouteIdentity(identity CommittedRouteIdentity) error { | |||
| 103 | + | if err := identity.Validate(); err != nil { | |||
| 104 | + | return fmt.Errorf("%w: committed identity: %v", ErrInvalidRouteWire, err) | |||
| 105 | + | } | |||
| 106 | + | return nil | |||
| 107 | + | } | |||
| 108 | + | ||||
| 109 | + | // MarshalCommittedRouteIdentity encodes the daemon's committed exact session | |||
| 110 | + | // identity without exposing transport or client-ledger details. | |||
| 111 | + | func MarshalCommittedRouteIdentity(identity CommittedRouteIdentity) ([]byte, error) { | |||
| 112 | + | if err := validateCommittedRouteIdentity(identity); err != nil { | |||
| 113 | + | return nil, err | |||
| 114 | + | } | |||
| 115 | + | w := payloadWriter{} | |||
| 116 | + | marshalExactSessionTarget(&w, identity.Target) | |||
| 117 | + | w.putBool(identity.Ephemeral) | |||
| 118 | + | return w.b, nil | |||
| 119 | + | } | |||
| 120 | + | ||||
| 121 | + | func marshalCommittedIdentitySection(w *payloadWriter, identity *CommittedRouteIdentity) { | |||
| 122 | + | w.putBool(identity != nil) | |||
| 123 | + | if identity != nil { | |||
| 124 | + | marshalExactSessionTarget(w, identity.Target) | |||
| 125 | + | w.putBool(identity.Ephemeral) | |||
| 126 | + | } | |||
| 127 | + | } | |||
| 128 | + | ||||
| 129 | + | func unmarshalCommittedIdentitySection(r *payloadReader) (*CommittedRouteIdentity, error) { | |||
| 130 | + | present, err := r.getBool() | |||
| 131 | + | if err != nil { | |||
| 132 | + | return nil, err | |||
| 133 | + | } | |||
| 134 | + | if !present { | |||
| 135 | + | return nil, nil | |||
| 136 | + | } | |||
| 137 | + | target, err := unmarshalExactSessionTarget(r) | |||
| 138 | + | if err != nil { | |||
| 139 | + | return nil, err | |||
| 140 | + | } | |||
| 141 | + | ephemeral, err := r.getBool() | |||
| 142 | + | if err != nil { | |||
| 143 | + | return nil, err | |||
| 144 | + | } | |||
| 145 | + | identity := &CommittedRouteIdentity{Target: target, Ephemeral: ephemeral} | |||
| 146 | + | if err := validateCommittedRouteIdentity(*identity); err != nil { | |||
| 147 | + | return nil, err | |||
| 148 | + | } | |||
| 149 | + | return identity, nil | |||
| 150 | + | } | |||
| 151 | + | ||||
| 152 | + | func UnmarshalCommittedRouteIdentity(b []byte) (CommittedRouteIdentity, error) { | |||
| 153 | + | r := payloadReader{b: b} | |||
| 154 | + | target, err := unmarshalExactSessionTarget(&r) | |||
| 155 | + | if err != nil { | |||
| 156 | + | return CommittedRouteIdentity{}, err | |||
| 157 | + | } | |||
| 158 | + | ephemeral, err := r.getBool() | |||
| 159 | + | if err != nil { | |||
| 160 | + | return CommittedRouteIdentity{}, err | |||
| 161 | + | } | |||
| 162 | + | if err := r.done(); err != nil { | |||
| 163 | + | return CommittedRouteIdentity{}, err | |||
| 164 | + | } | |||
| 165 | + | identity := CommittedRouteIdentity{Target: target, Ephemeral: ephemeral} | |||
| 166 | + | if err := validateCommittedRouteIdentity(identity); err != nil { | |||
| 167 | + | return CommittedRouteIdentity{}, err | |||
| 168 | + | } | |||
| 169 | + | return identity, nil | |||
| 170 | + | } | |||
| 171 | + | ||||
| 172 | + | func validateRouteRef(ref RouteRef) error { | |||
| 173 | + | if err := ref.Validate(); err != nil { | |||
| 174 | + | return fmt.Errorf("%w: route reference: %v", ErrInvalidRouteWire, err) | |||
| 175 | + | } | |||
| 176 | + | return nil | |||
| 177 | + | } | |||
| 178 | + | ||||
| 179 | + | func marshalRouteRef(w *payloadWriter, ref RouteRef) { | |||
| 180 | + | w.putUint64(ref.Key) | |||
| 181 | + | w.putUint64(ref.Generation) | |||
| 182 | + | } | |||
| 183 | + | ||||
| 184 | + | func unmarshalRouteRef(r *payloadReader) (RouteRef, error) { | |||
| 185 | + | key, err := r.getUint64() | |||
| 186 | + | if err != nil { | |||
| 187 | + | return RouteRef{}, err | |||
| 188 | + | } | |||
| 189 | + | generation, err := r.getUint64() | |||
| 190 | + | if err != nil { | |||
| 191 | + | return RouteRef{}, err | |||
| 192 | + | } | |||
| 193 | + | ref := RouteRef{Key: key, Generation: generation} | |||
| 194 | + | if err := validateRouteRef(ref); err != nil { | |||
| 195 | + | return RouteRef{}, err | |||
| 196 | + | } | |||
| 197 | + | return ref, nil | |||
| 198 | + | } | |||
| 199 | + | ||||
| 200 | + | func validateRecentRouteEntry(entry RecentRouteEntry) error { | |||
| 201 | + | if entry.Key == 0 || entry.Generation == 0 { | |||
| 202 | + | return fmt.Errorf("%w: route entry identity is zero", ErrInvalidRouteWire) | |||
| 203 | + | } | |||
| 204 | + | if err := validateRouteString(entry.Name, "route name", false); err != nil { | |||
| 205 | + | return fmt.Errorf("%w: %v", ErrInvalidRouteWire, err) | |||
| 206 | + | } | |||
| 207 | + | if err := validateRouteString(entry.HostLabel, "route host label", true); err != nil { | |||
| 208 | + | return fmt.Errorf("%w: %v", ErrInvalidRouteWire, err) | |||
| 209 | + | } | |||
| 210 | + | if !entry.Kind.valid() { | |||
| 211 | + | return fmt.Errorf("%w: invalid route kind", ErrInvalidRouteWire) | |||
| 212 | + | } | |||
| 213 | + | if !entry.Reachability.valid() { | |||
| 214 | + | return fmt.Errorf("%w: invalid route reachability", ErrInvalidRouteWire) | |||
| 215 | + | } | |||
| 216 | + | return nil | |||
| 217 | + | } | |||
| 218 | + | ||||
| 219 | + | func validateRecentRouteSnapshot(snapshot RecentRouteSnapshot) error { | |||
| 220 | + | if len(snapshot.Entries) > RouteSnapshotMaxEntries { | |||
| 221 | + | return fmt.Errorf("%w: too many route entries", ErrInvalidRouteWire) | |||
| 222 | + | } | |||
| 223 | + | if snapshot.Generation == 0 && len(snapshot.Entries) != 0 { | |||
| 224 | + | return fmt.Errorf("%w: non-empty snapshot has zero generation", ErrInvalidRouteWire) | |||
| 225 | + | } | |||
| 226 | + | if err := validateRouteRef(snapshot.Active); err != nil { | |||
| 227 | + | return err | |||
| 228 | + | } | |||
| 229 | + | if err := validateRouteRef(snapshot.Previous); err != nil { | |||
| 230 | + | return err | |||
| 231 | + | } | |||
| 232 | + | if err := validateRouteRef(snapshot.Home); err != nil { | |||
| 233 | + | return err | |||
| 234 | + | } | |||
| 235 | + | if !snapshot.Active.empty() && snapshot.Active == snapshot.Previous { | |||
| 236 | + | return fmt.Errorf("%w: active and previous routes are identical", ErrInvalidRouteWire) | |||
| 237 | + | } | |||
| 238 | + | refs := make(map[RouteRef]struct{}, len(snapshot.Entries)) | |||
| 239 | + | for _, entry := range snapshot.Entries { | |||
| 240 | + | if err := validateRecentRouteEntry(entry); err != nil { | |||
| 241 | + | return err | |||
| 242 | + | } | |||
| 243 | + | ref := RouteRef{Key: entry.Key, Generation: entry.Generation} | |||
| 244 | + | if _, exists := refs[ref]; exists { | |||
| 245 | + | return fmt.Errorf("%w: duplicate route entry", ErrInvalidRouteWire) | |||
| 246 | + | } | |||
| 247 | + | refs[ref] = struct{}{} | |||
| 248 | + | } | |||
| 249 | + | for _, ref := range []RouteRef{snapshot.Active, snapshot.Previous, snapshot.Home} { | |||
| 250 | + | if ref.empty() { | |||
| 251 | + | continue | |||
| 252 | + | } | |||
| 253 | + | if _, exists := refs[ref]; !exists { | |||
| 254 | + | return fmt.Errorf("%w: route reference is absent from snapshot", ErrInvalidRouteWire) | |||
| 255 | + | } | |||
| 256 | + | } | |||
| 257 | + | return nil | |||
| 258 | + | } | |||
| 259 | + | ||||
| 260 | + | // MarshalRecentRouteSnapshot encodes a complete bounded client route view. | |||
| 261 | + | func MarshalRecentRouteSnapshot(snapshot RecentRouteSnapshot) ([]byte, error) { | |||
| 262 | + | if err := validateRecentRouteSnapshot(snapshot); err != nil { | |||
| 263 | + | return nil, err | |||
| 264 | + | } | |||
| 265 | + | w := payloadWriter{} | |||
| 266 | + | w.putUint64(snapshot.Generation) | |||
| 267 | + | marshalRouteRef(&w, snapshot.Active) | |||
| 268 | + | marshalRouteRef(&w, snapshot.Previous) | |||
| 269 | + | marshalRouteRef(&w, snapshot.Home) | |||
| 270 | + | w.putUint8(uint8(len(snapshot.Entries))) | |||
| 271 | + | for _, entry := range snapshot.Entries { | |||
| 272 | + | w.putUint64(entry.Key) | |||
| 273 | + | w.putUint64(entry.Generation) | |||
| 274 | + | w.putString(entry.Name) | |||
| 275 | + | w.putString(entry.HostLabel) | |||
| 276 | + | w.putUint8(uint8(entry.Kind)) | |||
| 277 | + | w.putBool(entry.Ephemeral) | |||
| 278 | + | w.putBool(entry.Attention) | |||
| 279 | + | w.putUint8(uint8(entry.Reachability)) | |||
| 280 | + | } | |||
| 281 | + | if len(w.b) > MaxFrameLen-1 { | |||
| 282 | + | return nil, fmt.Errorf("%w: route snapshot is too large", ErrInvalidRouteWire) | |||
| 283 | + | } | |||
| 284 | + | return w.b, nil | |||
| 285 | + | } | |||
| 286 | + | ||||
| 287 | + | // UnmarshalRecentRouteSnapshot decodes and strictly validates one complete | |||
| 288 | + | // immutable snapshot, including every referenced active/previous/home entry. | |||
| 289 | + | func UnmarshalRecentRouteSnapshot(b []byte) (RecentRouteSnapshot, error) { | |||
| 290 | + | if len(b) > MaxFrameLen-1 { | |||
| 291 | + | return RecentRouteSnapshot{}, ErrInvalidRouteWire | |||
| 292 | + | } | |||
| 293 | + | r := payloadReader{b: b} | |||
| 294 | + | var snapshot RecentRouteSnapshot | |||
| 295 | + | var err error | |||
| 296 | + | if snapshot.Generation, err = r.getUint64(); err != nil { | |||
| 297 | + | return RecentRouteSnapshot{}, err | |||
| 298 | + | } | |||
| 299 | + | if snapshot.Active, err = unmarshalRouteRef(&r); err != nil { | |||
| 300 | + | return RecentRouteSnapshot{}, err | |||
| 301 | + | } | |||
| 302 | + | if snapshot.Previous, err = unmarshalRouteRef(&r); err != nil { | |||
| 303 | + | return RecentRouteSnapshot{}, err | |||
| 304 | + | } | |||
| 305 | + | if snapshot.Home, err = unmarshalRouteRef(&r); err != nil { | |||
| 306 | + | return RecentRouteSnapshot{}, err | |||
| 307 | + | } | |||
| 308 | + | count, err := r.getUint8() | |||
| 309 | + | if err != nil { | |||
| 310 | + | return RecentRouteSnapshot{}, err | |||
| 311 | + | } | |||
| 312 | + | if int(count) > RouteSnapshotMaxEntries { | |||
| 313 | + | return RecentRouteSnapshot{}, fmt.Errorf("%w: too many route entries", ErrInvalidRouteWire) | |||
| 314 | + | } | |||
| 315 | + | if count != 0 { | |||
| 316 | + | snapshot.Entries = make([]RecentRouteEntry, 0, int(count)) | |||
| 317 | + | } | |||
| 318 | + | for range int(count) { | |||
| 319 | + | var entry RecentRouteEntry | |||
| 320 | + | if entry.Key, err = r.getUint64(); err != nil { | |||
| 321 | + | return RecentRouteSnapshot{}, err | |||
| 322 | + | } | |||
| 323 | + | if entry.Generation, err = r.getUint64(); err != nil { | |||
| 324 | + | return RecentRouteSnapshot{}, err | |||
| 325 | + | } | |||
| 326 | + | if entry.Name, err = r.getString(); err != nil { | |||
| 327 | + | return RecentRouteSnapshot{}, err | |||
| 328 | + | } | |||
| 329 | + | if entry.HostLabel, err = r.getString(); err != nil { | |||
| 330 | + | return RecentRouteSnapshot{}, err | |||
| 331 | + | } | |||
| 332 | + | kind, err := r.getUint8() | |||
| 333 | + | if err != nil { | |||
| 334 | + | return RecentRouteSnapshot{}, err | |||
| 335 | + | } | |||
| 336 | + | entry.Kind = RouteKind(kind) | |||
| 337 | + | if entry.Ephemeral, err = r.getBool(); err != nil { | |||
| 338 | + | return RecentRouteSnapshot{}, err | |||
| 339 | + | } | |||
| 340 | + | if entry.Attention, err = r.getBool(); err != nil { | |||
| 341 | + | return RecentRouteSnapshot{}, err | |||
| 342 | + | } | |||
| 343 | + | reachability, err := r.getUint8() | |||
| 344 | + | if err != nil { | |||
| 345 | + | return RecentRouteSnapshot{}, err | |||
| 346 | + | } | |||
| 347 | + | entry.Reachability = RouteReachability(reachability) | |||
| 348 | + | snapshot.Entries = append(snapshot.Entries, entry) | |||
| 349 | + | } | |||
| 350 | + | if err := r.done(); err != nil { | |||
| 351 | + | return RecentRouteSnapshot{}, err | |||
| 352 | + | } | |||
| 353 | + | if err := validateRecentRouteSnapshot(snapshot); err != nil { | |||
| 354 | + | return RecentRouteSnapshot{}, err | |||
| 355 | + | } | |||
| 356 | + | return snapshot, nil | |||
| 357 | + | } | |||
| 358 | + | ||||
| 359 | + | func validateRouteNavigationAction(action RouteNavigationAction) error { | |||
| 360 | + | if action.Key == 0 || action.Generation == 0 { | |||
| 361 | + | return fmt.Errorf("%w: navigation action identity is zero", ErrInvalidRouteWire) | |||
| 362 | + | } | |||
| 363 | + | return nil | |||
| 364 | + | } | |||
| 365 | + | ||||
| 366 | + | func MarshalRouteNavigationAction(action RouteNavigationAction) ([]byte, error) { | |||
| 367 | + | if err := validateRouteNavigationAction(action); err != nil { | |||
| 368 | + | return nil, err | |||
| 369 | + | } | |||
| 370 | + | w := payloadWriter{} | |||
| 371 | + | w.putUint64(action.Key) | |||
| 372 | + | w.putUint64(action.Generation) | |||
| 373 | + | return w.b, nil | |||
| 374 | + | } | |||
| 375 | + | ||||
| 376 | + | func UnmarshalRouteNavigationAction(b []byte) (RouteNavigationAction, error) { | |||
| 377 | + | r := payloadReader{b: b} | |||
| 378 | + | var action RouteNavigationAction | |||
| 379 | + | var err error | |||
| 380 | + | if action.Key, err = r.getUint64(); err != nil { | |||
| 381 | + | return RouteNavigationAction{}, err | |||
| 382 | + | } | |||
| 383 | + | if action.Generation, err = r.getUint64(); err != nil { | |||
| 384 | + | return RouteNavigationAction{}, err | |||
| 385 | + | } | |||
| 386 | + | if err := r.done(); err != nil { | |||
| 387 | + | return RouteNavigationAction{}, err | |||
| 388 | + | } | |||
| 389 | + | if err := validateRouteNavigationAction(action); err != nil { | |||
| 390 | + | return RouteNavigationAction{}, err | |||
| 391 | + | } | |||
| 392 | + | return action, nil | |||
| 393 | + | } | |||
| 394 | + | ||||
| 395 | + | func validateRouteNavigationFailure(failure RouteNavigationFailure) error { | |||
| 396 | + | if failure.Key == 0 || failure.Generation == 0 { | |||
| 397 | + | return fmt.Errorf("%w: failure identity is zero", ErrInvalidRouteWire) | |||
| 398 | + | } | |||
| 399 | + | if !failure.Code.valid() { | |||
| 400 | + | return fmt.Errorf("%w: invalid failure code", ErrInvalidRouteWire) | |||
| 401 | + | } | |||
| 402 | + | return nil | |||
| 403 | + | } | |||
| 404 | + | ||||
| 405 | + | func MarshalRouteNavigationFailure(failure RouteNavigationFailure) ([]byte, error) { | |||
| 406 | + | if err := validateRouteNavigationFailure(failure); err != nil { | |||
| 407 | + | return nil, err | |||
| 408 | + | } | |||
| 409 | + | w := payloadWriter{} | |||
| 410 | + | w.putUint64(failure.Key) | |||
| 411 | + | w.putUint64(failure.Generation) | |||
| 412 | + | w.putUint8(uint8(failure.Code)) | |||
| 413 | + | return w.b, nil | |||
| 414 | + | } | |||
| 415 | + | ||||
| 416 | + | func UnmarshalRouteNavigationFailure(b []byte) (RouteNavigationFailure, error) { | |||
| 417 | + | r := payloadReader{b: b} | |||
| 418 | + | var failure RouteNavigationFailure | |||
| 419 | + | var err error | |||
| 420 | + | if failure.Key, err = r.getUint64(); err != nil { | |||
| 421 | + | return RouteNavigationFailure{}, err | |||
| 422 | + | } | |||
| 423 | + | if failure.Generation, err = r.getUint64(); err != nil { | |||
| 424 | + | return RouteNavigationFailure{}, err | |||
| 425 | + | } | |||
| 426 | + | code, err := r.getUint8() | |||
| 427 | + | if err != nil { | |||
| 428 | + | return RouteNavigationFailure{}, err | |||
| 429 | + | } | |||
| 430 | + | failure.Code = RouteFailureCode(code) | |||
| 431 | + | if err := r.done(); err != nil { | |||
| 432 | + | return RouteNavigationFailure{}, err | |||
| 433 | + | } | |||
| 434 | + | if err := validateRouteNavigationFailure(failure); err != nil { | |||
| 435 | + | return RouteNavigationFailure{}, err | |||
| 436 | + | } | |||
| 437 | + | return failure, nil | |||
| 438 | + | } | |||
M internal/ports/wire.go +42 -8
| @@ -160,7 +160,11 @@ type Hello struct { | |||
| 160 | 160 | // RemoteTarget is present only for a picker-selected remote attach. It is | |
| 161 | 161 | // carried through reconnects and is validated by the owning daemon before | |
| 162 | 162 | // it creates or publishes any session resources. | |
| 163 | - | RemoteTarget *domain.RemoteSessionTarget | |
| 163 | + | RemoteTarget *domain.RemoteSessionTarget | |
| 164 | + | // ExactTarget is an optional lifecycle/name identity selected by the client | |
| 165 | + | // ledger. It is transport-neutral and never replaces the daemon's authority | |
| 166 | + | // to validate or commit the target. | |
| 167 | + | ExactTarget *ExactSessionTarget | |
| 164 | 168 | EnvironmentPolicy EnvironmentPolicy | |
| 165 | 169 | NavigationCapabilities NavigationCapabilities | |
| 166 | 170 | StartupOverlay StartupOverlay | |
| @@ -250,11 +254,12 @@ type Ack struct { | |||
| 250 | 254 | ||
| 251 | 255 | // Welcome is the daemon's reply to a successful Hello. | |
| 252 | 256 | type Welcome struct { | |
| 253 | - | SessionID string | |
| 254 | - | SessionName string | |
| 255 | - | Ephemeral bool | |
| 256 | - | ResumeToken uint64 | |
| 257 | - | Capabilities uint32 | |
| 257 | + | SessionID string | |
| 258 | + | SessionName string | |
| 259 | + | Ephemeral bool | |
| 260 | + | ResumeToken uint64 | |
| 261 | + | Capabilities uint32 | |
| 262 | + | CommittedIdentity *CommittedRouteIdentity | |
| 258 | 263 | } | |
| 259 | 264 | ||
| 260 | 265 | // ErrorMsg reports a protocol- or session-level failure to the client. | |
| @@ -386,9 +391,10 @@ func (w *payloadWriter) putLongBytes(b []byte) { | |||
| 386 | 391 | w.b = append(w.b, b...) | |
| 387 | 392 | } | |
| 388 | 393 | ||
| 389 | - | // marshalRemoteTargetSection writes the required v26 target/policy section. | |
| 394 | + | // marshalRemoteTargetSection writes the required v27 target/policy section. | |
| 390 | 395 | // A target may be absent for direct local attaches, but its presence marker and | |
| 391 | - | // environment policy are never optional: v26 has no extension or legacy tail. | |
| 396 | + | // environment policy are never optional: v27 has no extension or legacy tail | |
| 397 | + | // inside this section; the exact-target section follows it. | |
| 392 | 398 | func marshalRemoteTargetSection(w *payloadWriter, target *domain.RemoteSessionTarget, policy EnvironmentPolicy) { | |
| 393 | 399 | if target == nil { | |
| 394 | 400 | w.putBool(false) | |
| @@ -718,6 +724,20 @@ func ValidateHello(h Hello) error { | |||
| 718 | 724 | return ErrInvalidHello | |
| 719 | 725 | } | |
| 720 | 726 | } | |
| 727 | + | if h.ExactTarget != nil { | |
| 728 | + | if h.Intent != IntentAttach && h.Intent != IntentResume { | |
| 729 | + | return ErrInvalidHello | |
| 730 | + | } | |
| 731 | + | if err := h.ExactTarget.Validate(); err != nil { | |
| 732 | + | return fmt.Errorf("%w: exact target: %v", ErrInvalidHello, err) | |
| 733 | + | } | |
| 734 | + | if h.Name != h.ExactTarget.SessionName { | |
| 735 | + | return ErrInvalidHello | |
| 736 | + | } | |
| 737 | + | if h.RemoteTarget != nil && (h.RemoteTarget.LifecycleID != h.ExactTarget.LifecycleID || h.RemoteTarget.SessionName != h.ExactTarget.SessionName) { | |
| 738 | + | return ErrInvalidHello | |
| 739 | + | } | |
| 740 | + | } | |
| 721 | 741 | if h.RemoteTarget == nil { | |
| 722 | 742 | if h.EnvironmentPolicy != EnvironmentPolicyClientOwned && h.EnvironmentPolicy != EnvironmentPolicyDaemonOwned { | |
| 723 | 743 | return ErrInvalidHello | |
| @@ -830,6 +850,7 @@ func MarshalHello(h Hello) []byte { | |||
| 830 | 850 | w.putLongString(entry) | |
| 831 | 851 | } | |
| 832 | 852 | marshalRemoteTargetSection(&w, h.RemoteTarget, h.EnvironmentPolicy) | |
| 853 | + | marshalExactTargetSection(&w, h.ExactTarget) | |
| 833 | 854 | w.putUint8(uint8(h.NavigationCapabilities)) | |
| 834 | 855 | w.putUint8(uint8(h.StartupOverlay)) | |
| 835 | 856 | return w.b | |
| @@ -890,6 +911,9 @@ func preflightHello(b []byte) error { | |||
| 890 | 911 | if err := skipRemoteTargetSection(&r); err != nil { | |
| 891 | 912 | return err | |
| 892 | 913 | } | |
| 914 | + | if err := skipExactTargetSection(&r); err != nil { | |
| 915 | + | return err | |
| 916 | + | } | |
| 893 | 917 | if _, err := r.getUint8(); err != nil { | |
| 894 | 918 | return err | |
| 895 | 919 | } | |
| @@ -971,6 +995,9 @@ func UnmarshalHello(b []byte) (Hello, error) { | |||
| 971 | 995 | if h.RemoteTarget, h.EnvironmentPolicy, err = unmarshalRemoteTargetSection(&r); err != nil { | |
| 972 | 996 | return Hello{}, err | |
| 973 | 997 | } | |
| 998 | + | if h.ExactTarget, err = unmarshalExactTargetSection(&r); err != nil { | |
| 999 | + | return Hello{}, err | |
| 1000 | + | } | |
| 974 | 1001 | capabilities, err := r.getUint8() | |
| 975 | 1002 | if err != nil { | |
| 976 | 1003 | return Hello{}, err | |
| @@ -1275,6 +1302,7 @@ func MarshalWelcome(m Welcome) []byte { | |||
| 1275 | 1302 | } | |
| 1276 | 1303 | w.putUint64(m.ResumeToken) | |
| 1277 | 1304 | w.putUint32(m.Capabilities) | |
| 1305 | + | marshalCommittedIdentitySection(&w, m.CommittedIdentity) | |
| 1278 | 1306 | return w.b | |
| 1279 | 1307 | } | |
| 1280 | 1308 | ||
| @@ -1301,6 +1329,12 @@ func UnmarshalWelcome(b []byte) (Welcome, error) { | |||
| 1301 | 1329 | if m.Capabilities, err = r.getUint32(); err != nil { | |
| 1302 | 1330 | return Welcome{}, err | |
| 1303 | 1331 | } | |
| 1332 | + | if m.CommittedIdentity, err = unmarshalCommittedIdentitySection(&r); err != nil { | |
| 1333 | + | return Welcome{}, err | |
| 1334 | + | } | |
| 1335 | + | if m.CommittedIdentity != nil && (m.CommittedIdentity.Target.SessionName != m.SessionName || m.CommittedIdentity.Ephemeral != m.Ephemeral) { | |
| 1336 | + | return Welcome{}, fmt.Errorf("%w: committed identity does not match welcome", ErrInvalidRouteWire) | |
| 1337 | + | } | |
| 1304 | 1338 | if err := r.done(); err != nil { | |
| 1305 | 1339 | return Welcome{}, err | |
| 1306 | 1340 | } | |
| @@ -160,7 +160,11 @@ type Hello struct { | |||||
| 160 | // RemoteTarget is present only for a picker-selected remote attach. It is | 160 | // RemoteTarget is present only for a picker-selected remote attach. It is | ||
| 161 | // carried through reconnects and is validated by the owning daemon before | 161 | // carried through reconnects and is validated by the owning daemon before | ||
| 162 | // it creates or publishes any session resources. | 162 | // it creates or publishes any session resources. | ||
| 163 | - | RemoteTarget *domain.RemoteSessionTarget | 163 | + | RemoteTarget *domain.RemoteSessionTarget |
| 164 | + | // ExactTarget is an optional lifecycle/name identity selected by the client | |||
| 165 | + | // ledger. It is transport-neutral and never replaces the daemon's authority | |||
| 166 | + | // to validate or commit the target. | |||
| 167 | + | ExactTarget *ExactSessionTarget | |||
| 164 | EnvironmentPolicy EnvironmentPolicy | 168 | EnvironmentPolicy EnvironmentPolicy | ||
| 165 | NavigationCapabilities NavigationCapabilities | 169 | NavigationCapabilities NavigationCapabilities | ||
| 166 | StartupOverlay StartupOverlay | 170 | StartupOverlay StartupOverlay | ||
| @@ -250,11 +254,12 @@ type Ack struct { | |||||
| 250 | 254 | ||||
| 251 | // Welcome is the daemon's reply to a successful Hello. | 255 | // Welcome is the daemon's reply to a successful Hello. | ||
| 252 | type Welcome struct { | 256 | type Welcome struct { | ||
| 253 | - | SessionID string | 257 | + | SessionID string |
| 254 | - | SessionName string | 258 | + | SessionName string |
| 255 | - | Ephemeral bool | 259 | + | Ephemeral bool |
| 256 | - | ResumeToken uint64 | 260 | + | ResumeToken uint64 |
| 257 | - | Capabilities uint32 | 261 | + | Capabilities uint32 |
| 262 | + | CommittedIdentity *CommittedRouteIdentity | |||
| 258 | } | 263 | } | ||
| 259 | 264 | ||||
| 260 | // ErrorMsg reports a protocol- or session-level failure to the client. | 265 | // ErrorMsg reports a protocol- or session-level failure to the client. | ||
| @@ -386,9 +391,10 @@ func (w *payloadWriter) putLongBytes(b []byte) { | |||||
| 386 | w.b = append(w.b, b...) | 391 | w.b = append(w.b, b...) | ||
| 387 | } | 392 | } | ||
| 388 | 393 | ||||
| 389 | - | // marshalRemoteTargetSection writes the required v26 target/policy section. | 394 | + | // marshalRemoteTargetSection writes the required v27 target/policy section. |
| 390 | // A target may be absent for direct local attaches, but its presence marker and | 395 | // A target may be absent for direct local attaches, but its presence marker and | ||
| 391 | - | // environment policy are never optional: v26 has no extension or legacy tail. | 396 | + | // environment policy are never optional: v27 has no extension or legacy tail |
| 397 | + | // inside this section; the exact-target section follows it. | |||
| 392 | func marshalRemoteTargetSection(w *payloadWriter, target *domain.RemoteSessionTarget, policy EnvironmentPolicy) { | 398 | func marshalRemoteTargetSection(w *payloadWriter, target *domain.RemoteSessionTarget, policy EnvironmentPolicy) { | ||
| 393 | if target == nil { | 399 | if target == nil { | ||
| 394 | w.putBool(false) | 400 | w.putBool(false) | ||
| @@ -718,6 +724,20 @@ func ValidateHello(h Hello) error { | |||||
| 718 | return ErrInvalidHello | 724 | return ErrInvalidHello | ||
| 719 | } | 725 | } | ||
| 720 | } | 726 | } | ||
| 727 | + | if h.ExactTarget != nil { | |||
| 728 | + | if h.Intent != IntentAttach && h.Intent != IntentResume { | |||
| 729 | + | return ErrInvalidHello | |||
| 730 | + | } | |||
| 731 | + | if err := h.ExactTarget.Validate(); err != nil { | |||
| 732 | + | return fmt.Errorf("%w: exact target: %v", ErrInvalidHello, err) | |||
| 733 | + | } | |||
| 734 | + | if h.Name != h.ExactTarget.SessionName { | |||
| 735 | + | return ErrInvalidHello | |||
| 736 | + | } | |||
| 737 | + | if h.RemoteTarget != nil && (h.RemoteTarget.LifecycleID != h.ExactTarget.LifecycleID || h.RemoteTarget.SessionName != h.ExactTarget.SessionName) { | |||
| 738 | + | return ErrInvalidHello | |||
| 739 | + | } | |||
| 740 | + | } | |||
| 721 | if h.RemoteTarget == nil { | 741 | if h.RemoteTarget == nil { | ||
| 722 | if h.EnvironmentPolicy != EnvironmentPolicyClientOwned && h.EnvironmentPolicy != EnvironmentPolicyDaemonOwned { | 742 | if h.EnvironmentPolicy != EnvironmentPolicyClientOwned && h.EnvironmentPolicy != EnvironmentPolicyDaemonOwned { | ||
| 723 | return ErrInvalidHello | 743 | return ErrInvalidHello | ||
| @@ -830,6 +850,7 @@ func MarshalHello(h Hello) []byte { | |||||
| 830 | w.putLongString(entry) | 850 | w.putLongString(entry) | ||
| 831 | } | 851 | } | ||
| 832 | marshalRemoteTargetSection(&w, h.RemoteTarget, h.EnvironmentPolicy) | 852 | marshalRemoteTargetSection(&w, h.RemoteTarget, h.EnvironmentPolicy) | ||
| 853 | + | marshalExactTargetSection(&w, h.ExactTarget) | |||
| 833 | w.putUint8(uint8(h.NavigationCapabilities)) | 854 | w.putUint8(uint8(h.NavigationCapabilities)) | ||
| 834 | w.putUint8(uint8(h.StartupOverlay)) | 855 | w.putUint8(uint8(h.StartupOverlay)) | ||
| 835 | return w.b | 856 | return w.b | ||
| @@ -890,6 +911,9 @@ func preflightHello(b []byte) error { | |||||
| 890 | if err := skipRemoteTargetSection(&r); err != nil { | 911 | if err := skipRemoteTargetSection(&r); err != nil { | ||
| 891 | return err | 912 | return err | ||
| 892 | } | 913 | } | ||
| 914 | + | if err := skipExactTargetSection(&r); err != nil { | |||
| 915 | + | return err | |||
| 916 | + | } | |||
| 893 | if _, err := r.getUint8(); err != nil { | 917 | if _, err := r.getUint8(); err != nil { | ||
| 894 | return err | 918 | return err | ||
| 895 | } | 919 | } | ||
| @@ -971,6 +995,9 @@ func UnmarshalHello(b []byte) (Hello, error) { | |||||
| 971 | if h.RemoteTarget, h.EnvironmentPolicy, err = unmarshalRemoteTargetSection(&r); err != nil { | 995 | if h.RemoteTarget, h.EnvironmentPolicy, err = unmarshalRemoteTargetSection(&r); err != nil { | ||
| 972 | return Hello{}, err | 996 | return Hello{}, err | ||
| 973 | } | 997 | } | ||
| 998 | + | if h.ExactTarget, err = unmarshalExactTargetSection(&r); err != nil { | |||
| 999 | + | return Hello{}, err | |||
| 1000 | + | } | |||
| 974 | capabilities, err := r.getUint8() | 1001 | capabilities, err := r.getUint8() | ||
| 975 | if err != nil { | 1002 | if err != nil { | ||
| 976 | return Hello{}, err | 1003 | return Hello{}, err | ||
| @@ -1275,6 +1302,7 @@ func MarshalWelcome(m Welcome) []byte { | |||||
| 1275 | } | 1302 | } | ||
| 1276 | w.putUint64(m.ResumeToken) | 1303 | w.putUint64(m.ResumeToken) | ||
| 1277 | w.putUint32(m.Capabilities) | 1304 | w.putUint32(m.Capabilities) | ||
| 1305 | + | marshalCommittedIdentitySection(&w, m.CommittedIdentity) | |||
| 1278 | return w.b | 1306 | return w.b | ||
| 1279 | } | 1307 | } | ||
| 1280 | 1308 | ||||
| @@ -1301,6 +1329,12 @@ func UnmarshalWelcome(b []byte) (Welcome, error) { | |||||
| 1301 | if m.Capabilities, err = r.getUint32(); err != nil { | 1329 | if m.Capabilities, err = r.getUint32(); err != nil { | ||
| 1302 | return Welcome{}, err | 1330 | return Welcome{}, err | ||
| 1303 | } | 1331 | } | ||
| 1332 | + | if m.CommittedIdentity, err = unmarshalCommittedIdentitySection(&r); err != nil { | |||
| 1333 | + | return Welcome{}, err | |||
| 1334 | + | } | |||
| 1335 | + | if m.CommittedIdentity != nil && (m.CommittedIdentity.Target.SessionName != m.SessionName || m.CommittedIdentity.Ephemeral != m.Ephemeral) { | |||
| 1336 | + | return Welcome{}, fmt.Errorf("%w: committed identity does not match welcome", ErrInvalidRouteWire) | |||
| 1337 | + | } | |||
| 1304 | if err := r.done(); err != nil { | 1338 | if err := r.done(); err != nil { | ||
| 1305 | return Welcome{}, err | 1339 | return Welcome{}, err | ||
| 1306 | } | 1340 | } | ||
M internal/ports/wire_final_test.go +4 -4
| @@ -11,8 +11,8 @@ import ( | |||
| 11 | 11 | ) | |
| 12 | 12 | ||
| 13 | 13 | func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) { | |
| 14 | - | if ProtocolVersion != 26 { | |
| 15 | - | t.Fatalf("ProtocolVersion = %d, want 26", ProtocolVersion) | |
| 14 | + | if ProtocolVersion != 27 { | |
| 15 | + | t.Fatalf("ProtocolVersion = %d, want 27", ProtocolVersion) | |
| 16 | 16 | } | |
| 17 | 17 | payload := MarshalHello(Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}}) | |
| 18 | 18 | if len(payload) < 2 { | |
| @@ -26,8 +26,8 @@ func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) { | |||
| 26 | 26 | ||
| 27 | 27 | func TestFinalHelloGoldenStrict(t *testing.T) { | |
| 28 | 28 | msg := Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}} | |
| 29 | - | want := append([]byte{0, 26, 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) | |
| 29 | + | want := append([]byte{0, 27, 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) | |
| 31 | 31 | got := MarshalHello(msg) | |
| 32 | 32 | if !bytes.Equal(got, want) { | |
| 33 | 33 | t.Fatalf("Hello bytes = %x, want %x", 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 != 26 { | 14 | + | if ProtocolVersion != 27 { |
| 15 | - | t.Fatalf("ProtocolVersion = %d, want 26", ProtocolVersion) | 15 | + | t.Fatalf("ProtocolVersion = %d, want 27", 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,8 +26,8 @@ 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, 26, 2}, make([]byte, 16+8)...) | 29 | + | want := append([]byte{0, 27, 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) | 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) |
| 31 | got := MarshalHello(msg) | 31 | got := MarshalHello(msg) | ||
| 32 | if !bytes.Equal(got, want) { | 32 | if !bytes.Equal(got, want) { | ||
| 33 | t.Fatalf("Hello bytes = %x, want %x", got, want) | 33 | t.Fatalf("Hello bytes = %x, want %x", got, want) | ||
M internal/ports/wire_test.go +12 -12
| @@ -92,7 +92,7 @@ func TestHelloGoldenAndRoundTrip(t *testing.T) { | |||
| 92 | 92 | ||
| 93 | 93 | for _, tt := range tests { | |
| 94 | 94 | t.Run(tt.name, func(t *testing.T) { | |
| 95 | - | want := append(append([]byte(nil), tt.want...), 0, 0) | |
| 95 | + | want := append(append([]byte(nil), tt.want...), 0, 0, 0) | |
| 96 | 96 | got := MarshalHello(tt.msg) | |
| 97 | 97 | if !bytes.Equal(got, want) { | |
| 98 | 98 | t.Fatalf("MarshalHello() = %#v, want %#v", got, want) | |
| @@ -125,7 +125,7 @@ func TestHelloEnvironmentCodec(t *testing.T) { | |||
| 125 | 125 | } | |
| 126 | 126 | got := MarshalHello(msg) | |
| 127 | 127 | want := []byte{ | |
| 128 | - | 0x00, 0x1a, 0x00, // version, intent, first client ID byte | |
| 128 | + | 0x00, 0x1b, 0x00, // version, intent, first client ID byte | |
| 129 | 129 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | |
| 130 | 130 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID | |
| 131 | 131 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token | |
| @@ -139,7 +139,7 @@ func TestHelloEnvironmentCodec(t *testing.T) { | |||
| 139 | 139 | 0x00, 0x00, 0x00, 0x06, 'X', 'Y', '=', '1', '2', '3', | |
| 140 | 140 | 0x00, 0x00, // no remote target, client-owned environment | |
| 141 | 141 | } | |
| 142 | - | want = append(want, 0, 0) | |
| 142 | + | want = append(want, 0, 0, 0) | |
| 143 | 143 | require.Equal(t, want, got) | |
| 144 | 144 | decoded, err := UnmarshalHello(want) | |
| 145 | 145 | require.NoError(t, err) | |
| @@ -167,7 +167,7 @@ func TestHelloEnvironmentCodec(t *testing.T) { | |||
| 167 | 167 | base := MarshalHello(Hello{Version: ProtocolVersion, Size: domain.Size{Cols: 1, Rows: 1}}) | |
| 168 | 168 | withCount := func(count byte) []byte { | |
| 169 | 169 | payload := append([]byte(nil), base...) | |
| 170 | - | payload[len(payload)-5] = count | |
| 170 | + | payload[len(payload)-6] = count | |
| 171 | 171 | return payload | |
| 172 | 172 | } | |
| 173 | 173 | tests := []struct { | |
| @@ -344,7 +344,7 @@ func TestThemeGenerationClearedWireGoldenPreservesProtocolVersion(t *testing.T) | |||
| 344 | 344 | } | |
| 345 | 345 | want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...) | |
| 346 | 346 | require.Equal(t, want, MarshalTheme(cleared)) | |
| 347 | - | require.Equal(t, uint16(26), ProtocolVersion) | |
| 347 | + | require.Equal(t, uint16(27), ProtocolVersion) | |
| 348 | 348 | } | |
| 349 | 349 | ||
| 350 | 350 | func TestResizeGoldenAndRoundTrip(t *testing.T) { | |
| @@ -424,12 +424,12 @@ func TestWelcomeGoldenAndRoundTrip(t *testing.T) { | |||
| 424 | 424 | { | |
| 425 | 425 | name: "ephemeral", | |
| 426 | 426 | msg: Welcome{SessionID: "sess-1", SessionName: "main", Ephemeral: true, ResumeToken: 0x0102030405060708, Capabilities: CapabilityResume | CapabilityPredict}, | |
| 427 | - | want: []byte{0x00, 0x06, 0x73, 0x65, 0x73, 0x73, 0x2d, 0x31, 0x00, 0x04, 0x6d, 0x61, 0x69, 0x6e, 0x01, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x00, 0x00, 0x00, 0x05}, | |
| 427 | + | want: []byte{0x00, 0x06, 0x73, 0x65, 0x73, 0x73, 0x2d, 0x31, 0x00, 0x04, 0x6d, 0x61, 0x69, 0x6e, 0x01, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x00, 0x00, 0x00, 0x05, 0x00}, | |
| 428 | 428 | }, | |
| 429 | 429 | { | |
| 430 | 430 | name: "non-ephemeral empty name", | |
| 431 | 431 | msg: Welcome{SessionID: "abc", SessionName: "", Ephemeral: false}, | |
| 432 | - | want: []byte{0x00, 0x03, 0x61, 0x62, 0x63, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, | |
| 432 | + | want: []byte{0x00, 0x03, 0x61, 0x62, 0x63, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, | |
| 433 | 433 | }, | |
| 434 | 434 | } | |
| 435 | 435 | ||
| @@ -472,7 +472,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) { | |||
| 472 | 472 | name: "minimal", | |
| 473 | 473 | msg: CommandRequest{Version: ProtocolVersion, Slug: "split-right"}, | |
| 474 | 474 | want: []byte{ | |
| 475 | - | 0x00, 0x1a, | |
| 475 | + | 0x00, 0x1b, | |
| 476 | 476 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID | |
| 477 | 477 | 0x00, // attached | |
| 478 | 478 | 0x00, // self | |
| @@ -495,7 +495,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) { | |||
| 495 | 495 | JSON: true, | |
| 496 | 496 | }, | |
| 497 | 497 | want: []byte{ | |
| 498 | - | 0x00, 0x1a, | |
| 498 | + | 0x00, 0x1b, | |
| 499 | 499 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID | |
| 500 | 500 | 0x00, // attached | |
| 501 | 501 | 0x01, // self | |
| @@ -894,7 +894,7 @@ func TestHelloOutputWindowByteExactValues(t *testing.T) { | |||
| 894 | 894 | hello := Hello{Version: 14, Size: domain.Size{Cols: 1, Rows: 1}, MaxOutputInFlight: window} | |
| 895 | 895 | // The empty Hello has a fixed 1x1 size before the negotiated output window. | |
| 896 | 896 | want := append(make([]byte, 38), window, 0, 0, 0, 0, 0, 0) | |
| 897 | - | want = append(want, 0, 0) | |
| 897 | + | want = append(want, 0, 0, 0) | |
| 898 | 898 | want[1] = 14 | |
| 899 | 899 | want[30], want[32] = 1, 1 | |
| 900 | 900 | got := MarshalHello(hello) | |
| @@ -924,7 +924,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) { | |||
| 924 | 924 | name: "attached", | |
| 925 | 925 | msg: CommandRequest{Version: ProtocolVersion, RequestID: 0x0102030405060708, Attached: true, Self: true, Slug: "split-right", Args: []string{"--vertical"}}, | |
| 926 | 926 | want: []byte{ | |
| 927 | - | 0x00, 0x1a, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x01, 0x01, | |
| 927 | + | 0x00, 0x1b, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x01, 0x01, | |
| 928 | 928 | 0x00, 0x0b, 's', 'p', 'l', 'i', 't', '-', 'r', 'i', 'g', 'h', 't', | |
| 929 | 929 | 0x00, 0x01, 0x00, 0x00, 0x00, 0x0a, '-', '-', 'v', 'e', 'r', 't', 'i', 'c', 'a', 'l', | |
| 930 | 930 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | |
| @@ -934,7 +934,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) { | |||
| 934 | 934 | name: "control", | |
| 935 | 935 | msg: CommandRequest{Version: ProtocolVersion, Slug: "ls"}, | |
| 936 | 936 | want: []byte{ | |
| 937 | - | 0x00, 0x1a, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | |
| 937 | + | 0x00, 0x1b, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | |
| 938 | 938 | 0x00, 0x02, 'l', 's', 0x00, 0x00, | |
| 939 | 939 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | |
| 940 | 940 | }, | |
| @@ -92,7 +92,7 @@ func TestHelloGoldenAndRoundTrip(t *testing.T) { | |||||
| 92 | 92 | ||||
| 93 | for _, tt := range tests { | 93 | for _, tt := range tests { | ||
| 94 | t.Run(tt.name, func(t *testing.T) { | 94 | t.Run(tt.name, func(t *testing.T) { | ||
| 95 | - | want := append(append([]byte(nil), tt.want...), 0, 0) | 95 | + | want := append(append([]byte(nil), tt.want...), 0, 0, 0) |
| 96 | got := MarshalHello(tt.msg) | 96 | got := MarshalHello(tt.msg) | ||
| 97 | if !bytes.Equal(got, want) { | 97 | if !bytes.Equal(got, want) { | ||
| 98 | t.Fatalf("MarshalHello() = %#v, want %#v", got, want) | 98 | t.Fatalf("MarshalHello() = %#v, want %#v", got, want) | ||
| @@ -125,7 +125,7 @@ func TestHelloEnvironmentCodec(t *testing.T) { | |||||
| 125 | } | 125 | } | ||
| 126 | got := MarshalHello(msg) | 126 | got := MarshalHello(msg) | ||
| 127 | want := []byte{ | 127 | want := []byte{ | ||
| 128 | - | 0x00, 0x1a, 0x00, // version, intent, first client ID byte | 128 | + | 0x00, 0x1b, 0x00, // version, intent, first client ID byte |
| 129 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | 129 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | ||
| 130 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID | 130 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID | ||
| 131 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token | 131 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token | ||
| @@ -139,7 +139,7 @@ func TestHelloEnvironmentCodec(t *testing.T) { | |||||
| 139 | 0x00, 0x00, 0x00, 0x06, 'X', 'Y', '=', '1', '2', '3', | 139 | 0x00, 0x00, 0x00, 0x06, 'X', 'Y', '=', '1', '2', '3', | ||
| 140 | 0x00, 0x00, // no remote target, client-owned environment | 140 | 0x00, 0x00, // no remote target, client-owned environment | ||
| 141 | } | 141 | } | ||
| 142 | - | want = append(want, 0, 0) | 142 | + | want = append(want, 0, 0, 0) |
| 143 | require.Equal(t, want, got) | 143 | require.Equal(t, want, got) | ||
| 144 | decoded, err := UnmarshalHello(want) | 144 | decoded, err := UnmarshalHello(want) | ||
| 145 | require.NoError(t, err) | 145 | require.NoError(t, err) | ||
| @@ -167,7 +167,7 @@ func TestHelloEnvironmentCodec(t *testing.T) { | |||||
| 167 | base := MarshalHello(Hello{Version: ProtocolVersion, Size: domain.Size{Cols: 1, Rows: 1}}) | 167 | base := MarshalHello(Hello{Version: ProtocolVersion, Size: domain.Size{Cols: 1, Rows: 1}}) | ||
| 168 | withCount := func(count byte) []byte { | 168 | withCount := func(count byte) []byte { | ||
| 169 | payload := append([]byte(nil), base...) | 169 | payload := append([]byte(nil), base...) | ||
| 170 | - | payload[len(payload)-5] = count | 170 | + | payload[len(payload)-6] = count |
| 171 | return payload | 171 | return payload | ||
| 172 | } | 172 | } | ||
| 173 | tests := []struct { | 173 | tests := []struct { | ||
| @@ -344,7 +344,7 @@ func TestThemeGenerationClearedWireGoldenPreservesProtocolVersion(t *testing.T) | |||||
| 344 | } | 344 | } | ||
| 345 | want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...) | 345 | want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...) | ||
| 346 | require.Equal(t, want, MarshalTheme(cleared)) | 346 | require.Equal(t, want, MarshalTheme(cleared)) | ||
| 347 | - | require.Equal(t, uint16(26), ProtocolVersion) | 347 | + | require.Equal(t, uint16(27), ProtocolVersion) |
| 348 | } | 348 | } | ||
| 349 | 349 | ||||
| 350 | func TestResizeGoldenAndRoundTrip(t *testing.T) { | 350 | func TestResizeGoldenAndRoundTrip(t *testing.T) { | ||
| @@ -424,12 +424,12 @@ func TestWelcomeGoldenAndRoundTrip(t *testing.T) { | |||||
| 424 | { | 424 | { | ||
| 425 | name: "ephemeral", | 425 | name: "ephemeral", | ||
| 426 | msg: Welcome{SessionID: "sess-1", SessionName: "main", Ephemeral: true, ResumeToken: 0x0102030405060708, Capabilities: CapabilityResume | CapabilityPredict}, | 426 | msg: Welcome{SessionID: "sess-1", SessionName: "main", Ephemeral: true, ResumeToken: 0x0102030405060708, Capabilities: CapabilityResume | CapabilityPredict}, | ||
| 427 | - | want: []byte{0x00, 0x06, 0x73, 0x65, 0x73, 0x73, 0x2d, 0x31, 0x00, 0x04, 0x6d, 0x61, 0x69, 0x6e, 0x01, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x00, 0x00, 0x00, 0x05}, | 427 | + | want: []byte{0x00, 0x06, 0x73, 0x65, 0x73, 0x73, 0x2d, 0x31, 0x00, 0x04, 0x6d, 0x61, 0x69, 0x6e, 0x01, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x00, 0x00, 0x00, 0x05, 0x00}, |
| 428 | }, | 428 | }, | ||
| 429 | { | 429 | { | ||
| 430 | name: "non-ephemeral empty name", | 430 | name: "non-ephemeral empty name", | ||
| 431 | msg: Welcome{SessionID: "abc", SessionName: "", Ephemeral: false}, | 431 | msg: Welcome{SessionID: "abc", SessionName: "", Ephemeral: false}, | ||
| 432 | - | want: []byte{0x00, 0x03, 0x61, 0x62, 0x63, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, | 432 | + | want: []byte{0x00, 0x03, 0x61, 0x62, 0x63, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, |
| 433 | }, | 433 | }, | ||
| 434 | } | 434 | } | ||
| 435 | 435 | ||||
| @@ -472,7 +472,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) { | |||||
| 472 | name: "minimal", | 472 | name: "minimal", | ||
| 473 | msg: CommandRequest{Version: ProtocolVersion, Slug: "split-right"}, | 473 | msg: CommandRequest{Version: ProtocolVersion, Slug: "split-right"}, | ||
| 474 | want: []byte{ | 474 | want: []byte{ | ||
| 475 | - | 0x00, 0x1a, | 475 | + | 0x00, 0x1b, |
| 476 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID | 476 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID | ||
| 477 | 0x00, // attached | 477 | 0x00, // attached | ||
| 478 | 0x00, // self | 478 | 0x00, // self | ||
| @@ -495,7 +495,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) { | |||||
| 495 | JSON: true, | 495 | JSON: true, | ||
| 496 | }, | 496 | }, | ||
| 497 | want: []byte{ | 497 | want: []byte{ | ||
| 498 | - | 0x00, 0x1a, | 498 | + | 0x00, 0x1b, |
| 499 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID | 499 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID | ||
| 500 | 0x00, // attached | 500 | 0x00, // attached | ||
| 501 | 0x01, // self | 501 | 0x01, // self | ||
| @@ -894,7 +894,7 @@ func TestHelloOutputWindowByteExactValues(t *testing.T) { | |||||
| 894 | hello := Hello{Version: 14, Size: domain.Size{Cols: 1, Rows: 1}, MaxOutputInFlight: window} | 894 | hello := Hello{Version: 14, Size: domain.Size{Cols: 1, Rows: 1}, MaxOutputInFlight: window} | ||
| 895 | // The empty Hello has a fixed 1x1 size before the negotiated output window. | 895 | // The empty Hello has a fixed 1x1 size before the negotiated output window. | ||
| 896 | want := append(make([]byte, 38), window, 0, 0, 0, 0, 0, 0) | 896 | want := append(make([]byte, 38), window, 0, 0, 0, 0, 0, 0) | ||
| 897 | - | want = append(want, 0, 0) | 897 | + | want = append(want, 0, 0, 0) |
| 898 | want[1] = 14 | 898 | want[1] = 14 | ||
| 899 | want[30], want[32] = 1, 1 | 899 | want[30], want[32] = 1, 1 | ||
| 900 | got := MarshalHello(hello) | 900 | got := MarshalHello(hello) | ||
| @@ -924,7 +924,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) { | |||||
| 924 | name: "attached", | 924 | name: "attached", | ||
| 925 | msg: CommandRequest{Version: ProtocolVersion, RequestID: 0x0102030405060708, Attached: true, Self: true, Slug: "split-right", Args: []string{"--vertical"}}, | 925 | msg: CommandRequest{Version: ProtocolVersion, RequestID: 0x0102030405060708, Attached: true, Self: true, Slug: "split-right", Args: []string{"--vertical"}}, | ||
| 926 | want: []byte{ | 926 | want: []byte{ | ||
| 927 | - | 0x00, 0x1a, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x01, 0x01, | 927 | + | 0x00, 0x1b, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x01, 0x01, |
| 928 | 0x00, 0x0b, 's', 'p', 'l', 'i', 't', '-', 'r', 'i', 'g', 'h', 't', | 928 | 0x00, 0x0b, 's', 'p', 'l', 'i', 't', '-', 'r', 'i', 'g', 'h', 't', | ||
| 929 | 0x00, 0x01, 0x00, 0x00, 0x00, 0x0a, '-', '-', 'v', 'e', 'r', 't', 'i', 'c', 'a', 'l', | 929 | 0x00, 0x01, 0x00, 0x00, 0x00, 0x0a, '-', '-', 'v', 'e', 'r', 't', 'i', 'c', 'a', 'l', | ||
| 930 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | 930 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | ||
| @@ -934,7 +934,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) { | |||||
| 934 | name: "control", | 934 | name: "control", | ||
| 935 | msg: CommandRequest{Version: ProtocolVersion, Slug: "ls"}, | 935 | msg: CommandRequest{Version: ProtocolVersion, Slug: "ls"}, | ||
| 936 | want: []byte{ | 936 | want: []byte{ | ||
| 937 | - | 0x00, 0x1a, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | 937 | + | 0x00, 0x1b, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, |
| 938 | 0x00, 0x02, 'l', 's', 0x00, 0x00, | 938 | 0x00, 0x02, 'l', 's', 0x00, 0x00, | ||
| 939 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | 939 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | ||
| 940 | }, | 940 | }, | ||
M internal/usecase/client/client.go +65 -8
| @@ -163,6 +163,9 @@ type Dependencies struct { | |||
| 163 | 163 | // Remote selects client-side carriage presentation only; it never enters | |
| 164 | 164 | // the daemon-facing session request. | |
| 165 | 165 | Remote bool | |
| 166 | + | // Origin is explicit composition metadata for the initial route. A zero | |
| 167 | + | // value is normalized to local for direct package callers and old tests. | |
| 168 | + | Origin ports.RouteOrigin | |
| 166 | 169 | } | |
| 167 | 170 | ||
| 168 | 171 | // AttachRequest identifies the session for one client run. Remote is | |
| @@ -172,7 +175,9 @@ type AttachRequest struct { | |||
| 172 | 175 | Intent uint8 | |
| 173 | 176 | SessionName string | |
| 174 | 177 | Remote bool | |
| 178 | + | Origin ports.RouteOrigin | |
| 175 | 179 | RemoteTarget *domain.RemoteSessionTarget | |
| 180 | + | ExactTarget *ports.ExactSessionTarget | |
| 176 | 181 | EnvironmentPolicy ports.EnvironmentPolicy | |
| 177 | 182 | NavigationCapabilities ports.NavigationCapabilities | |
| 178 | 183 | StartupOverlay ports.StartupOverlay | |
| @@ -196,6 +201,8 @@ type Runner struct { | |||
| 196 | 201 | remoteHostLearner ports.RemoteHostLearner | |
| 197 | 202 | attachHandoff AttachHandoffFunc | |
| 198 | 203 | remote bool | |
| 204 | + | origin ports.RouteOrigin | |
| 205 | + | ledger *routeLedger | |
| 199 | 206 | ||
| 200 | 207 | inputMu sync.Mutex | |
| 201 | 208 | input *terminalInputPump | |
| @@ -217,6 +224,8 @@ func NewRunner(deps Dependencies) *Runner { | |||
| 217 | 224 | remoteHostLearner: deps.RemoteHostLearner, | |
| 218 | 225 | attachHandoff: deps.AttachHandoff, | |
| 219 | 226 | remote: deps.Remote, | |
| 227 | + | origin: normalizeRouteOrigin(deps.Origin, deps.Remote), | |
| 228 | + | ledger: newRouteLedger(), | |
| 220 | 229 | } | |
| 221 | 230 | } | |
| 222 | 231 | ||
| @@ -236,6 +245,22 @@ func (r *Runner) terminalInput() *terminalInputPump { | |||
| 236 | 245 | } | |
| 237 | 246 | ||
| 238 | 247 | func validateAttachRequest(request AttachRequest) error { | |
| 248 | + | if request.Origin != 0 { | |
| 249 | + | if err := request.Origin.Validate(); err != nil { | |
| 250 | + | return fmt.Errorf("vev: invalid route origin: %w", err) | |
| 251 | + | } | |
| 252 | + | } | |
| 253 | + | if request.ExactTarget != nil { | |
| 254 | + | if err := request.ExactTarget.Validate(); err != nil { | |
| 255 | + | return fmt.Errorf("vev: invalid exact target: %w", err) | |
| 256 | + | } | |
| 257 | + | if request.SessionName != request.ExactTarget.SessionName { | |
| 258 | + | return errors.New("vev: exact target session mismatch") | |
| 259 | + | } | |
| 260 | + | if request.RemoteTarget != nil && (request.RemoteTarget.LifecycleID != request.ExactTarget.LifecycleID || request.RemoteTarget.SessionName != request.ExactTarget.SessionName) { | |
| 261 | + | return errors.New("vev: exact target does not match remote target") | |
| 262 | + | } | |
| 263 | + | } | |
| 239 | 264 | if err := (SessionTarget{Intent: request.Intent, SessionName: request.SessionName}).validate(); err != nil { | |
| 240 | 265 | return fmt.Errorf("vev: invalid session target: %w", err) | |
| 241 | 266 | } | |
| @@ -271,6 +296,9 @@ func validateAttachRequest(request AttachRequest) error { | |||
| 271 | 296 | // redials a lost link. | |
| 272 | 297 | func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error) { | |
| 273 | 298 | request = cloneAttachRequest(request) | |
| 299 | + | if request.Origin == 0 { | |
| 300 | + | request.Origin = r.origin | |
| 301 | + | } | |
| 274 | 302 | if err := validateAttachRequest(request); err != nil { | |
| 275 | 303 | return err | |
| 276 | 304 | } | |
| @@ -477,6 +505,18 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error) | |||
| 477 | 505 | }).run(ctx) | |
| 478 | 506 | stopHandshakeTransport() | |
| 479 | 507 | finishHandshake() | |
| 508 | + | if result.welcomed && result.err == nil && result.committedIdentity != nil && r.ledger != nil { | |
| 509 | + | if attemptRequest.ExactTarget != nil && *attemptRequest.ExactTarget != result.committedIdentity.Target { | |
| 510 | + | return errRouteTargetChanged | |
| 511 | + | } | |
| 512 | + | if attemptRequest.RemoteTarget != nil && (attemptRequest.RemoteTarget.LifecycleID != result.committedIdentity.Target.LifecycleID || attemptRequest.RemoteTarget.SessionName != result.committedIdentity.Target.SessionName) { | |
| 513 | + | return errRouteTargetChanged | |
| 514 | + | } | |
| 515 | + | home := r.ledger.homeRef().Key == 0 | |
| 516 | + | if _, err := r.ledger.commit(routeCandidateForAttach(attemptRequest, *result.committedIdentity, dialer, result.resumeToken, home)); err != nil { | |
| 517 | + | return fmt.Errorf("vev: committing route identity: %w", err) | |
| 518 | + | } | |
| 519 | + | } | |
| 480 | 520 | if result.welcomed { | |
| 481 | 521 | backoff = defaultReconnectBackoff.initial | |
| 482 | 522 | } | |
| @@ -764,13 +804,14 @@ type attachAttempt struct { | |||
| 764 | 804 | } | |
| 765 | 805 | ||
| 766 | 806 | type attachResult struct { | |
| 767 | - | resumeToken uint64 | |
| 768 | - | sessionName string | |
| 769 | - | welcomed bool | |
| 770 | - | transportClosed bool | |
| 771 | - | target *ports.AttachTarget | |
| 772 | - | action ports.NavigationAction | |
| 773 | - | err error | |
| 807 | + | resumeToken uint64 | |
| 808 | + | sessionName string | |
| 809 | + | committedIdentity *ports.CommittedRouteIdentity | |
| 810 | + | welcomed bool | |
| 811 | + | transportClosed bool | |
| 812 | + | target *ports.AttachTarget | |
| 813 | + | action ports.NavigationAction | |
| 814 | + | err error | |
| 774 | 815 | } | |
| 775 | 816 | ||
| 776 | 817 | // AttachTargetError requests that the composition root replace the current | |
| @@ -877,6 +918,13 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||
| 877 | 918 | colorTerm := os.Getenv("COLORTERM") | |
| 878 | 919 | trueColor := DetectTrueColor(termEnv, colorTerm) | |
| 879 | 920 | themeState.setTrueColor(trueColor) | |
| 921 | + | exactTarget := request.ExactTarget | |
| 922 | + | if exactTarget == nil && request.RemoteTarget != nil { | |
| 923 | + | exactTarget = &ports.ExactSessionTarget{ | |
| 924 | + | LifecycleID: request.RemoteTarget.LifecycleID, | |
| 925 | + | SessionName: request.RemoteTarget.SessionName, | |
| 926 | + | } | |
| 927 | + | } | |
| 880 | 928 | hello := ports.Hello{ | |
| 881 | 929 | Version: ports.ProtocolVersion, | |
| 882 | 930 | Intent: intent, | |
| @@ -890,6 +938,7 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||
| 890 | 938 | MaxOutputInFlight: requestedOutputWindow(transport), | |
| 891 | 939 | Env: os.Environ(), | |
| 892 | 940 | RemoteTarget: request.RemoteTarget, | |
| 941 | + | ExactTarget: exactTarget, | |
| 893 | 942 | EnvironmentPolicy: request.EnvironmentPolicy, | |
| 894 | 943 | NavigationCapabilities: request.NavigationCapabilities, | |
| 895 | 944 | StartupOverlay: request.StartupOverlay, | |
| @@ -910,8 +959,15 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||
| 910 | 959 | }); err != nil { | |
| 911 | 960 | return handshakeFailure("awaiting welcome", err) | |
| 912 | 961 | } | |
| 962 | + | var committedIdentity *ports.CommittedRouteIdentity | |
| 913 | 963 | result := func(welcomed bool, err error) attachResult { | |
| 914 | - | return attachResult{resumeToken: resumeToken, sessionName: name, welcomed: welcomed, err: err} | |
| 964 | + | return attachResult{ | |
| 965 | + | resumeToken: resumeToken, | |
| 966 | + | sessionName: name, | |
| 967 | + | committedIdentity: cloneCommittedIdentity(committedIdentity), | |
| 968 | + | welcomed: welcomed, | |
| 969 | + | err: err, | |
| 970 | + | } | |
| 915 | 971 | } | |
| 916 | 972 | switch reply.Type { | |
| 917 | 973 | case ports.MsgWelcome: | |
| @@ -921,6 +977,7 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||
| 921 | 977 | } | |
| 922 | 978 | resumeToken = welcome.ResumeToken | |
| 923 | 979 | name = welcome.SessionName | |
| 980 | + | committedIdentity = cloneCommittedIdentity(welcome.CommittedIdentity) | |
| 924 | 981 | ms.welcomed = true | |
| 925 | 982 | log.Debug("welcomed by daemon", "resume_token_present", resumeToken != 0) | |
| 926 | 983 | if remote { | |
| @@ -163,6 +163,9 @@ type Dependencies struct { | |||||
| 163 | // Remote selects client-side carriage presentation only; it never enters | 163 | // Remote selects client-side carriage presentation only; it never enters | ||
| 164 | // the daemon-facing session request. | 164 | // the daemon-facing session request. | ||
| 165 | Remote bool | 165 | Remote bool | ||
| 166 | + | // Origin is explicit composition metadata for the initial route. A zero | |||
| 167 | + | // value is normalized to local for direct package callers and old tests. | |||
| 168 | + | Origin ports.RouteOrigin | |||
| 166 | } | 169 | } | ||
| 167 | 170 | ||||
| 168 | // AttachRequest identifies the session for one client run. Remote is | 171 | // AttachRequest identifies the session for one client run. Remote is | ||
| @@ -172,7 +175,9 @@ type AttachRequest struct { | |||||
| 172 | Intent uint8 | 175 | Intent uint8 | ||
| 173 | SessionName string | 176 | SessionName string | ||
| 174 | Remote bool | 177 | Remote bool | ||
| 178 | + | Origin ports.RouteOrigin | |||
| 175 | RemoteTarget *domain.RemoteSessionTarget | 179 | RemoteTarget *domain.RemoteSessionTarget | ||
| 180 | + | ExactTarget *ports.ExactSessionTarget | |||
| 176 | EnvironmentPolicy ports.EnvironmentPolicy | 181 | EnvironmentPolicy ports.EnvironmentPolicy | ||
| 177 | NavigationCapabilities ports.NavigationCapabilities | 182 | NavigationCapabilities ports.NavigationCapabilities | ||
| 178 | StartupOverlay ports.StartupOverlay | 183 | StartupOverlay ports.StartupOverlay | ||
| @@ -196,6 +201,8 @@ type Runner struct { | |||||
| 196 | remoteHostLearner ports.RemoteHostLearner | 201 | remoteHostLearner ports.RemoteHostLearner | ||
| 197 | attachHandoff AttachHandoffFunc | 202 | attachHandoff AttachHandoffFunc | ||
| 198 | remote bool | 203 | remote bool | ||
| 204 | + | origin ports.RouteOrigin | |||
| 205 | + | ledger *routeLedger | |||
| 199 | 206 | ||||
| 200 | inputMu sync.Mutex | 207 | inputMu sync.Mutex | ||
| 201 | input *terminalInputPump | 208 | input *terminalInputPump | ||
| @@ -217,6 +224,8 @@ func NewRunner(deps Dependencies) *Runner { | |||||
| 217 | remoteHostLearner: deps.RemoteHostLearner, | 224 | remoteHostLearner: deps.RemoteHostLearner, | ||
| 218 | attachHandoff: deps.AttachHandoff, | 225 | attachHandoff: deps.AttachHandoff, | ||
| 219 | remote: deps.Remote, | 226 | remote: deps.Remote, | ||
| 227 | + | origin: normalizeRouteOrigin(deps.Origin, deps.Remote), | |||
| 228 | + | ledger: newRouteLedger(), | |||
| 220 | } | 229 | } | ||
| 221 | } | 230 | } | ||
| 222 | 231 | ||||
| @@ -236,6 +245,22 @@ func (r *Runner) terminalInput() *terminalInputPump { | |||||
| 236 | } | 245 | } | ||
| 237 | 246 | ||||
| 238 | func validateAttachRequest(request AttachRequest) error { | 247 | func validateAttachRequest(request AttachRequest) error { | ||
| 248 | + | if request.Origin != 0 { | |||
| 249 | + | if err := request.Origin.Validate(); err != nil { | |||
| 250 | + | return fmt.Errorf("vev: invalid route origin: %w", err) | |||
| 251 | + | } | |||
| 252 | + | } | |||
| 253 | + | if request.ExactTarget != nil { | |||
| 254 | + | if err := request.ExactTarget.Validate(); err != nil { | |||
| 255 | + | return fmt.Errorf("vev: invalid exact target: %w", err) | |||
| 256 | + | } | |||
| 257 | + | if request.SessionName != request.ExactTarget.SessionName { | |||
| 258 | + | return errors.New("vev: exact target session mismatch") | |||
| 259 | + | } | |||
| 260 | + | if request.RemoteTarget != nil && (request.RemoteTarget.LifecycleID != request.ExactTarget.LifecycleID || request.RemoteTarget.SessionName != request.ExactTarget.SessionName) { | |||
| 261 | + | return errors.New("vev: exact target does not match remote target") | |||
| 262 | + | } | |||
| 263 | + | } | |||
| 239 | if err := (SessionTarget{Intent: request.Intent, SessionName: request.SessionName}).validate(); err != nil { | 264 | if err := (SessionTarget{Intent: request.Intent, SessionName: request.SessionName}).validate(); err != nil { | ||
| 240 | return fmt.Errorf("vev: invalid session target: %w", err) | 265 | return fmt.Errorf("vev: invalid session target: %w", err) | ||
| 241 | } | 266 | } | ||
| @@ -271,6 +296,9 @@ func validateAttachRequest(request AttachRequest) error { | |||||
| 271 | // redials a lost link. | 296 | // redials a lost link. | ||
| 272 | func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error) { | 297 | func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error) { | ||
| 273 | request = cloneAttachRequest(request) | 298 | request = cloneAttachRequest(request) | ||
| 299 | + | if request.Origin == 0 { | |||
| 300 | + | request.Origin = r.origin | |||
| 301 | + | } | |||
| 274 | if err := validateAttachRequest(request); err != nil { | 302 | if err := validateAttachRequest(request); err != nil { | ||
| 275 | return err | 303 | return err | ||
| 276 | } | 304 | } | ||
| @@ -477,6 +505,18 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error) | |||||
| 477 | }).run(ctx) | 505 | }).run(ctx) | ||
| 478 | stopHandshakeTransport() | 506 | stopHandshakeTransport() | ||
| 479 | finishHandshake() | 507 | finishHandshake() | ||
| 508 | + | if result.welcomed && result.err == nil && result.committedIdentity != nil && r.ledger != nil { | |||
| 509 | + | if attemptRequest.ExactTarget != nil && *attemptRequest.ExactTarget != result.committedIdentity.Target { | |||
| 510 | + | return errRouteTargetChanged | |||
| 511 | + | } | |||
| 512 | + | if attemptRequest.RemoteTarget != nil && (attemptRequest.RemoteTarget.LifecycleID != result.committedIdentity.Target.LifecycleID || attemptRequest.RemoteTarget.SessionName != result.committedIdentity.Target.SessionName) { | |||
| 513 | + | return errRouteTargetChanged | |||
| 514 | + | } | |||
| 515 | + | home := r.ledger.homeRef().Key == 0 | |||
| 516 | + | if _, err := r.ledger.commit(routeCandidateForAttach(attemptRequest, *result.committedIdentity, dialer, result.resumeToken, home)); err != nil { | |||
| 517 | + | return fmt.Errorf("vev: committing route identity: %w", err) | |||
| 518 | + | } | |||
| 519 | + | } | |||
| 480 | if result.welcomed { | 520 | if result.welcomed { | ||
| 481 | backoff = defaultReconnectBackoff.initial | 521 | backoff = defaultReconnectBackoff.initial | ||
| 482 | } | 522 | } | ||
| @@ -764,13 +804,14 @@ type attachAttempt struct { | |||||
| 764 | } | 804 | } | ||
| 765 | 805 | ||||
| 766 | type attachResult struct { | 806 | type attachResult struct { | ||
| 767 | - | resumeToken uint64 | 807 | + | resumeToken uint64 |
| 768 | - | sessionName string | 808 | + | sessionName string |
| 769 | - | welcomed bool | 809 | + | committedIdentity *ports.CommittedRouteIdentity |
| 770 | - | transportClosed bool | 810 | + | welcomed bool |
| 771 | - | target *ports.AttachTarget | 811 | + | transportClosed bool |
| 772 | - | action ports.NavigationAction | 812 | + | target *ports.AttachTarget |
| 773 | - | err error | 813 | + | action ports.NavigationAction |
| 814 | + | err error | |||
| 774 | } | 815 | } | ||
| 775 | 816 | ||||
| 776 | // AttachTargetError requests that the composition root replace the current | 817 | // AttachTargetError requests that the composition root replace the current | ||
| @@ -877,6 +918,13 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||||
| 877 | colorTerm := os.Getenv("COLORTERM") | 918 | colorTerm := os.Getenv("COLORTERM") | ||
| 878 | trueColor := DetectTrueColor(termEnv, colorTerm) | 919 | trueColor := DetectTrueColor(termEnv, colorTerm) | ||
| 879 | themeState.setTrueColor(trueColor) | 920 | themeState.setTrueColor(trueColor) | ||
| 921 | + | exactTarget := request.ExactTarget | |||
| 922 | + | if exactTarget == nil && request.RemoteTarget != nil { | |||
| 923 | + | exactTarget = &ports.ExactSessionTarget{ | |||
| 924 | + | LifecycleID: request.RemoteTarget.LifecycleID, | |||
| 925 | + | SessionName: request.RemoteTarget.SessionName, | |||
| 926 | + | } | |||
| 927 | + | } | |||
| 880 | hello := ports.Hello{ | 928 | hello := ports.Hello{ | ||
| 881 | Version: ports.ProtocolVersion, | 929 | Version: ports.ProtocolVersion, | ||
| 882 | Intent: intent, | 930 | Intent: intent, | ||
| @@ -890,6 +938,7 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||||
| 890 | MaxOutputInFlight: requestedOutputWindow(transport), | 938 | MaxOutputInFlight: requestedOutputWindow(transport), | ||
| 891 | Env: os.Environ(), | 939 | Env: os.Environ(), | ||
| 892 | RemoteTarget: request.RemoteTarget, | 940 | RemoteTarget: request.RemoteTarget, | ||
| 941 | + | ExactTarget: exactTarget, | |||
| 893 | EnvironmentPolicy: request.EnvironmentPolicy, | 942 | EnvironmentPolicy: request.EnvironmentPolicy, | ||
| 894 | NavigationCapabilities: request.NavigationCapabilities, | 943 | NavigationCapabilities: request.NavigationCapabilities, | ||
| 895 | StartupOverlay: request.StartupOverlay, | 944 | StartupOverlay: request.StartupOverlay, | ||
| @@ -910,8 +959,15 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||||
| 910 | }); err != nil { | 959 | }); err != nil { | ||
| 911 | return handshakeFailure("awaiting welcome", err) | 960 | return handshakeFailure("awaiting welcome", err) | ||
| 912 | } | 961 | } | ||
| 962 | + | var committedIdentity *ports.CommittedRouteIdentity | |||
| 913 | result := func(welcomed bool, err error) attachResult { | 963 | result := func(welcomed bool, err error) attachResult { | ||
| 914 | - | return attachResult{resumeToken: resumeToken, sessionName: name, welcomed: welcomed, err: err} | 964 | + | return attachResult{ |
| 965 | + | resumeToken: resumeToken, | |||
| 966 | + | sessionName: name, | |||
| 967 | + | committedIdentity: cloneCommittedIdentity(committedIdentity), | |||
| 968 | + | welcomed: welcomed, | |||
| 969 | + | err: err, | |||
| 970 | + | } | |||
| 915 | } | 971 | } | ||
| 916 | switch reply.Type { | 972 | switch reply.Type { | ||
| 917 | case ports.MsgWelcome: | 973 | case ports.MsgWelcome: | ||
| @@ -921,6 +977,7 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||||
| 921 | } | 977 | } | ||
| 922 | resumeToken = welcome.ResumeToken | 978 | resumeToken = welcome.ResumeToken | ||
| 923 | name = welcome.SessionName | 979 | name = welcome.SessionName | ||
| 980 | + | committedIdentity = cloneCommittedIdentity(welcome.CommittedIdentity) | |||
| 924 | ms.welcomed = true | 981 | ms.welcomed = true | ||
| 925 | log.Debug("welcomed by daemon", "resume_token_present", resumeToken != 0) | 982 | log.Debug("welcomed by daemon", "resume_token_present", resumeToken != 0) | ||
| 926 | if remote { | 983 | if remote { | ||
A internal/usecase/client/routes.go +468 -0
| @@ -0,0 +1,468 @@ | |||
| 1 | + | package client | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "fmt" | |
| 7 | + | "sync" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/vev/internal/ports" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | const maxRouteLedgerEntries = ports.RouteSnapshotMaxEntries | |
| 13 | + | ||
| 14 | + | var ( | |
| 15 | + | errRouteResumeUnavailable = errors.New("route resume unavailable") | |
| 16 | + | errRouteStaleSelection = errors.New("route selection is stale") | |
| 17 | + | errRouteTargetChanged = errors.New("route target changed") | |
| 18 | + | ) | |
| 19 | + | ||
| 20 | + | // routeKey is process-local identity. It is never derived from a daemon | |
| 21 | + | // lifecycle ID and is not exposed outside the client ledger. | |
| 22 | + | type routeKey uint64 | |
| 23 | + | ||
| 24 | + | type routeGeneration uint64 | |
| 25 | + | ||
| 26 | + | type routeIdentity struct { | |
| 27 | + | key routeKey | |
| 28 | + | generation routeGeneration | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | func (i routeIdentity) empty() bool { return i.key == 0 && i.generation == 0 } | |
| 32 | + | ||
| 33 | + | func (i routeIdentity) wire() ports.RouteRef { | |
| 34 | + | return ports.RouteRef{Key: uint64(i.key), Generation: uint64(i.generation)} | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | func identityFromWire(ref ports.RouteRef) routeIdentity { | |
| 38 | + | return routeIdentity{key: routeKey(ref.Key), generation: routeGeneration(ref.Generation)} | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | // routePresentation contains only immutable display data. Connection and | |
| 42 | + | // attach capabilities live in routeRecord beside it and are never copied into | |
| 43 | + | // a RecentRouteSnapshot. | |
| 44 | + | type routePresentation struct { | |
| 45 | + | name string | |
| 46 | + | hostLabel string | |
| 47 | + | kind ports.RouteKind | |
| 48 | + | ephemeral bool | |
| 49 | + | attention bool | |
| 50 | + | reachability ports.RouteReachability | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | type routeCandidate struct { | |
| 54 | + | origin ports.RouteOrigin | |
| 55 | + | target ports.ExactSessionTarget | |
| 56 | + | presentation routePresentation | |
| 57 | + | dialer ports.Dialer | |
| 58 | + | request AttachRequest | |
| 59 | + | resumeToken uint64 | |
| 60 | + | home bool | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | type routeRecord struct { | |
| 64 | + | identity routeIdentity | |
| 65 | + | origin ports.RouteOrigin | |
| 66 | + | target ports.ExactSessionTarget | |
| 67 | + | presentation routePresentation | |
| 68 | + | dialer ports.Dialer | |
| 69 | + | request AttachRequest | |
| 70 | + | resumeToken uint64 | |
| 71 | + | home bool | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | // routeLedger owns client-global route identity/history for one client | |
| 75 | + | // process. Its lock protects both the bounded record set and active/previous | |
| 76 | + | // references; snapshots are copied while holding the lock and are immutable | |
| 77 | + | // after publication. | |
| 78 | + | type routeLedger struct { | |
| 79 | + | mu sync.RWMutex | |
| 80 | + | nextKey routeKey | |
| 81 | + | generation routeGeneration | |
| 82 | + | entries []routeRecord | |
| 83 | + | active routeIdentity | |
| 84 | + | previous routeIdentity | |
| 85 | + | home routeIdentity | |
| 86 | + | ||
| 87 | + | discoveryMu sync.Mutex | |
| 88 | + | discoveryCycle uint64 | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | func newRouteLedger() *routeLedger { return &routeLedger{} } | |
| 92 | + | ||
| 93 | + | func cloneCommittedIdentity(identity *ports.CommittedRouteIdentity) *ports.CommittedRouteIdentity { | |
| 94 | + | if identity == nil { | |
| 95 | + | return nil | |
| 96 | + | } | |
| 97 | + | copy := *identity | |
| 98 | + | return © | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | func routeCandidateForAttach(request AttachRequest, identity ports.CommittedRouteIdentity, dialer ports.Dialer, resumeToken uint64, home bool) routeCandidate { | |
| 102 | + | origin := normalizeRouteOrigin(request.Origin, request.Remote) | |
| 103 | + | kind := ports.RouteKindLocal | |
| 104 | + | hostLabel := "" | |
| 105 | + | if request.Remote || request.RemoteTarget != nil || origin != ports.RouteOriginLocal { | |
| 106 | + | kind = ports.RouteKindRemote | |
| 107 | + | if request.RemoteTarget != nil { | |
| 108 | + | hostLabel = request.RemoteTarget.DisplayOrigin | |
| 109 | + | } | |
| 110 | + | } | |
| 111 | + | request = cloneAttachRequest(request) | |
| 112 | + | request.Origin = origin | |
| 113 | + | request.SessionName = identity.Target.SessionName | |
| 114 | + | request.ExactTarget = &identity.Target | |
| 115 | + | return routeCandidate{ | |
| 116 | + | origin: origin, | |
| 117 | + | target: identity.Target, | |
| 118 | + | presentation: routePresentation{ | |
| 119 | + | name: identity.Target.SessionName, | |
| 120 | + | hostLabel: hostLabel, | |
| 121 | + | kind: kind, | |
| 122 | + | ephemeral: identity.Ephemeral, | |
| 123 | + | reachability: ports.RouteReachabilityReachable, | |
| 124 | + | }, | |
| 125 | + | dialer: dialer, | |
| 126 | + | request: request, | |
| 127 | + | resumeToken: resumeToken, | |
| 128 | + | home: home, | |
| 129 | + | } | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | func normalizeRouteOrigin(origin ports.RouteOrigin, remote bool) ports.RouteOrigin { | |
| 133 | + | if origin != 0 { | |
| 134 | + | return origin | |
| 135 | + | } | |
| 136 | + | if remote { | |
| 137 | + | return ports.RouteOriginRemote | |
| 138 | + | } | |
| 139 | + | return ports.RouteOriginLocal | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | func validateRoutePresentation(p routePresentation) error { | |
| 143 | + | if p.name == "" { | |
| 144 | + | return errors.New("route name is empty") | |
| 145 | + | } | |
| 146 | + | if len(p.name) > ports.RouteLabelMaxBytes || len(p.hostLabel) > ports.RouteLabelMaxBytes { | |
| 147 | + | return errors.New("route label is too long") | |
| 148 | + | } | |
| 149 | + | if err := p.kind.Validate(); err != nil { | |
| 150 | + | return err | |
| 151 | + | } | |
| 152 | + | if err := p.reachability.Validate(); err != nil { | |
| 153 | + | return err | |
| 154 | + | } | |
| 155 | + | return nil | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | func (l *routeLedger) nextIdentityLocked() (routeIdentity, error) { | |
| 159 | + | if l.nextKey == ^routeKey(0) || l.generation == ^routeGeneration(0) { | |
| 160 | + | return routeIdentity{}, errors.New("route ledger identity space exhausted") | |
| 161 | + | } | |
| 162 | + | l.nextKey++ | |
| 163 | + | l.generation++ | |
| 164 | + | return routeIdentity{key: l.nextKey, generation: l.generation}, nil | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | func (l *routeLedger) findByTargetLocked(origin ports.RouteOrigin, target ports.ExactSessionTarget) int { | |
| 168 | + | for i := range l.entries { | |
| 169 | + | entry := l.entries[i] | |
| 170 | + | if entry.origin == origin && entry.target == target { | |
| 171 | + | return i | |
| 172 | + | } | |
| 173 | + | } | |
| 174 | + | return -1 | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | func (l *routeLedger) moveToFrontLocked(index int, entry routeRecord) { | |
| 178 | + | if index < 0 { | |
| 179 | + | l.entries = append([]routeRecord{entry}, l.entries...) | |
| 180 | + | return | |
| 181 | + | } | |
| 182 | + | l.entries = append(l.entries[:index], l.entries[index+1:]...) | |
| 183 | + | l.entries = append([]routeRecord{entry}, l.entries...) | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | func (l *routeLedger) removeAtLocked(index int) { | |
| 187 | + | if index < 0 || index >= len(l.entries) { | |
| 188 | + | return | |
| 189 | + | } | |
| 190 | + | removed := l.entries[index].identity | |
| 191 | + | l.entries = append(l.entries[:index], l.entries[index+1:]...) | |
| 192 | + | for _, ref := range []*routeIdentity{&l.active, &l.previous, &l.home} { | |
| 193 | + | if *ref == removed { | |
| 194 | + | *ref = routeIdentity{} | |
| 195 | + | } | |
| 196 | + | } | |
| 197 | + | } | |
| 198 | + | ||
| 199 | + | func (l *routeLedger) evictLocked() { | |
| 200 | + | for len(l.entries) > maxRouteLedgerEntries { | |
| 201 | + | index := len(l.entries) - 1 | |
| 202 | + | for i := len(l.entries) - 1; i >= 0; i-- { | |
| 203 | + | identity := l.entries[i].identity | |
| 204 | + | if identity != l.active && identity != l.previous && identity != l.home { | |
| 205 | + | index = i | |
| 206 | + | break | |
| 207 | + | } | |
| 208 | + | } | |
| 209 | + | l.removeAtLocked(index) | |
| 210 | + | } | |
| 211 | + | } | |
| 212 | + | ||
| 213 | + | func (l *routeLedger) commit(candidate routeCandidate) (routeIdentity, error) { | |
| 214 | + | if err := candidate.origin.Validate(); err != nil { | |
| 215 | + | return routeIdentity{}, err | |
| 216 | + | } | |
| 217 | + | if err := candidate.target.Validate(); err != nil { | |
| 218 | + | return routeIdentity{}, err | |
| 219 | + | } | |
| 220 | + | if err := validateRoutePresentation(candidate.presentation); err != nil { | |
| 221 | + | return routeIdentity{}, err | |
| 222 | + | } | |
| 223 | + | candidate.request = cloneAttachRequest(candidate.request) | |
| 224 | + | candidate.request.Origin = candidate.origin | |
| 225 | + | candidate.request.ExactTarget = &candidate.target | |
| 226 | + | ||
| 227 | + | l.mu.Lock() | |
| 228 | + | defer l.mu.Unlock() | |
| 229 | + | return l.commitLocked(candidate) | |
| 230 | + | } | |
| 231 | + | ||
| 232 | + | func (l *routeLedger) commitLocked(candidate routeCandidate) (routeIdentity, error) { | |
| 233 | + | identity, err := l.nextIdentityLocked() | |
| 234 | + | if err != nil { | |
| 235 | + | return routeIdentity{}, err | |
| 236 | + | } | |
| 237 | + | index := l.findByTargetLocked(candidate.origin, candidate.target) | |
| 238 | + | if index >= 0 { | |
| 239 | + | identity.key = l.entries[index].identity.key | |
| 240 | + | } | |
| 241 | + | entry := routeRecord{ | |
| 242 | + | identity: identity, | |
| 243 | + | origin: candidate.origin, | |
| 244 | + | target: candidate.target, | |
| 245 | + | presentation: candidate.presentation, | |
| 246 | + | dialer: candidate.dialer, | |
| 247 | + | request: candidate.request, | |
| 248 | + | resumeToken: candidate.resumeToken, | |
| 249 | + | home: candidate.home, | |
| 250 | + | } | |
| 251 | + | oldActive := l.active | |
| 252 | + | l.moveToFrontLocked(index, entry) | |
| 253 | + | if !oldActive.empty() && oldActive.key != identity.key { | |
| 254 | + | l.previous = oldActive | |
| 255 | + | } | |
| 256 | + | if l.previous.key == identity.key { | |
| 257 | + | l.previous = routeIdentity{} | |
| 258 | + | } | |
| 259 | + | l.active = identity | |
| 260 | + | if candidate.home { | |
| 261 | + | l.home = identity | |
| 262 | + | } | |
| 263 | + | if l.home.key == identity.key { | |
| 264 | + | l.home.generation = identity.generation | |
| 265 | + | } | |
| 266 | + | if l.previous.key != 0 { | |
| 267 | + | if previousIndex := l.indexByIdentityLocked(l.previous); previousIndex < 0 { | |
| 268 | + | l.previous = routeIdentity{} | |
| 269 | + | } | |
| 270 | + | } | |
| 271 | + | l.evictLocked() | |
| 272 | + | return identity, nil | |
| 273 | + | } | |
| 274 | + | ||
| 275 | + | func (l *routeLedger) indexByIdentityLocked(identity routeIdentity) int { | |
| 276 | + | for i := range l.entries { | |
| 277 | + | if l.entries[i].identity == identity { | |
| 278 | + | return i | |
| 279 | + | } | |
| 280 | + | } | |
| 281 | + | return -1 | |
| 282 | + | } | |
| 283 | + | ||
| 284 | + | func (l *routeLedger) lookup(ref ports.RouteRef) (routeRecord, bool) { | |
| 285 | + | identity := identityFromWire(ref) | |
| 286 | + | if identity.empty() { | |
| 287 | + | return routeRecord{}, false | |
| 288 | + | } | |
| 289 | + | l.mu.RLock() | |
| 290 | + | defer l.mu.RUnlock() | |
| 291 | + | for _, entry := range l.entries { | |
| 292 | + | if entry.identity == identity { | |
| 293 | + | return cloneRouteRecord(entry), true | |
| 294 | + | } | |
| 295 | + | } | |
| 296 | + | return routeRecord{}, false | |
| 297 | + | } | |
| 298 | + | ||
| 299 | + | func cloneRouteRecord(entry routeRecord) routeRecord { | |
| 300 | + | entry.request = cloneAttachRequest(entry.request) | |
| 301 | + | return entry | |
| 302 | + | } | |
| 303 | + | ||
| 304 | + | func (l *routeLedger) snapshot() ports.RecentRouteSnapshot { | |
| 305 | + | l.mu.RLock() | |
| 306 | + | defer l.mu.RUnlock() | |
| 307 | + | ||
| 308 | + | snapshot := ports.RecentRouteSnapshot{ | |
| 309 | + | Generation: uint64(l.generation), | |
| 310 | + | Active: l.active.wire(), | |
| 311 | + | Previous: l.previous.wire(), | |
| 312 | + | Home: l.home.wire(), | |
| 313 | + | } | |
| 314 | + | if len(l.entries) == 0 { | |
| 315 | + | return snapshot | |
| 316 | + | } | |
| 317 | + | snapshot.Entries = make([]ports.RecentRouteEntry, 0, len(l.entries)) | |
| 318 | + | for _, entry := range l.entries { | |
| 319 | + | snapshot.Entries = append(snapshot.Entries, ports.RecentRouteEntry{ | |
| 320 | + | Key: uint64(entry.identity.key), | |
| 321 | + | Generation: uint64(entry.identity.generation), | |
| 322 | + | Name: entry.presentation.name, | |
| 323 | + | HostLabel: entry.presentation.hostLabel, | |
| 324 | + | Kind: entry.presentation.kind, | |
| 325 | + | Ephemeral: entry.presentation.ephemeral, | |
| 326 | + | Attention: entry.presentation.attention, | |
| 327 | + | Reachability: entry.presentation.reachability, | |
| 328 | + | }) | |
| 329 | + | } | |
| 330 | + | return snapshot | |
| 331 | + | } | |
| 332 | + | ||
| 333 | + | func (l *routeLedger) activeRef() ports.RouteRef { | |
| 334 | + | l.mu.RLock() | |
| 335 | + | defer l.mu.RUnlock() | |
| 336 | + | return l.active.wire() | |
| 337 | + | } | |
| 338 | + | ||
| 339 | + | func (l *routeLedger) previousRef() ports.RouteRef { | |
| 340 | + | l.mu.RLock() | |
| 341 | + | defer l.mu.RUnlock() | |
| 342 | + | return l.previous.wire() | |
| 343 | + | } | |
| 344 | + | ||
| 345 | + | func (l *routeLedger) homeRef() ports.RouteRef { | |
| 346 | + | l.mu.RLock() | |
| 347 | + | defer l.mu.RUnlock() | |
| 348 | + | return l.home.wire() | |
| 349 | + | } | |
| 350 | + | ||
| 351 | + | // routeTransitionPlan is the private lookup result used by navigation. It | |
| 352 | + | // preserves the original origin and exact target while leaving display labels | |
| 353 | + | // and transport capabilities separate. | |
| 354 | + | type routeTransitionPlan struct { | |
| 355 | + | identity routeIdentity | |
| 356 | + | record routeRecord | |
| 357 | + | origin ports.RouteOrigin | |
| 358 | + | target ports.ExactSessionTarget | |
| 359 | + | } | |
| 360 | + | ||
| 361 | + | type routeTransitionCommit struct { | |
| 362 | + | identity ports.CommittedRouteIdentity | |
| 363 | + | presentation routePresentation | |
| 364 | + | dialer ports.Dialer | |
| 365 | + | resumeToken uint64 | |
| 366 | + | } | |
| 367 | + | ||
| 368 | + | type routeTransitionConnector interface { | |
| 369 | + | Resume(context.Context, routeRecord) (routeTransitionCommit, error) | |
| 370 | + | Attach(context.Context, routeRecord) (routeTransitionCommit, error) | |
| 371 | + | } | |
| 372 | + | ||
| 373 | + | // navigate resolves a captured key/generation, attempts resume before exact | |
| 374 | + | // attach, and commits only after the connector returns a matching identity. | |
| 375 | + | // A failed transition leaves active/previous history unchanged. | |
| 376 | + | func (l *routeLedger) navigate(ctx context.Context, action ports.RouteNavigationAction, connector routeTransitionConnector) error { | |
| 377 | + | if action.Key == 0 || action.Generation == 0 { | |
| 378 | + | return errRouteStaleSelection | |
| 379 | + | } | |
| 380 | + | if connector == nil { | |
| 381 | + | return errors.New("route transition connector is nil") | |
| 382 | + | } | |
| 383 | + | if ctx == nil { | |
| 384 | + | ctx = context.Background() | |
| 385 | + | } | |
| 386 | + | selected := ports.RouteRef{Key: action.Key, Generation: action.Generation} | |
| 387 | + | record, ok := l.lookup(selected) | |
| 388 | + | if !ok { | |
| 389 | + | return errRouteStaleSelection | |
| 390 | + | } | |
| 391 | + | plan := routeTransitionPlan{ | |
| 392 | + | identity: identityFromWire(selected), | |
| 393 | + | record: record, | |
| 394 | + | origin: record.origin, | |
| 395 | + | target: record.target, | |
| 396 | + | } | |
| 397 | + | ||
| 398 | + | var commit routeTransitionCommit | |
| 399 | + | var err error | |
| 400 | + | if record.resumeToken != 0 { | |
| 401 | + | commit, err = connector.Resume(ctx, record) | |
| 402 | + | if errors.Is(err, errRouteResumeUnavailable) { | |
| 403 | + | commit, err = connector.Attach(ctx, record) | |
| 404 | + | } | |
| 405 | + | } else { | |
| 406 | + | commit, err = connector.Attach(ctx, record) | |
| 407 | + | } | |
| 408 | + | if err != nil { | |
| 409 | + | return err | |
| 410 | + | } | |
| 411 | + | if err := commit.identity.Validate(); err != nil { | |
| 412 | + | return fmt.Errorf("invalid committed route identity: %w", err) | |
| 413 | + | } | |
| 414 | + | if commit.identity.Target != plan.target { | |
| 415 | + | return errRouteTargetChanged | |
| 416 | + | } | |
| 417 | + | if err := validateRoutePresentation(commit.presentation); err != nil { | |
| 418 | + | return err | |
| 419 | + | } | |
| 420 | + | ||
| 421 | + | candidate := routeCandidate{ | |
| 422 | + | origin: plan.origin, | |
| 423 | + | target: plan.target, | |
| 424 | + | presentation: commit.presentation, | |
| 425 | + | dialer: commit.dialer, | |
| 426 | + | request: cloneAttachRequest(plan.record.request), | |
| 427 | + | resumeToken: commit.resumeToken, | |
| 428 | + | home: plan.record.home, | |
| 429 | + | } | |
| 430 | + | candidate.request.Origin = plan.origin | |
| 431 | + | candidate.request.ExactTarget = &candidate.target | |
| 432 | + | return l.commitTransition(plan.identity, candidate) | |
| 433 | + | } | |
| 434 | + | ||
| 435 | + | func (l *routeLedger) commitTransition(selected routeIdentity, candidate routeCandidate) error { | |
| 436 | + | l.mu.Lock() | |
| 437 | + | defer l.mu.Unlock() | |
| 438 | + | index := l.indexByIdentityLocked(selected) | |
| 439 | + | if index < 0 { | |
| 440 | + | return errRouteStaleSelection | |
| 441 | + | } | |
| 442 | + | if _, err := l.commitLocked(candidate); err != nil { | |
| 443 | + | return err | |
| 444 | + | } | |
| 445 | + | return nil | |
| 446 | + | } | |
| 447 | + | ||
| 448 | + | // beginDiscoveryCycle and discoveryCycleCurrent deliberately use a lock | |
| 449 | + | // separate from route history. A stale discovery result cannot clear or commit | |
| 450 | + | // unrelated navigation history. | |
| 451 | + | func (l *routeLedger) beginDiscoveryCycle() uint64 { | |
| 452 | + | l.discoveryMu.Lock() | |
| 453 | + | defer l.discoveryMu.Unlock() | |
| 454 | + | l.discoveryCycle++ | |
| 455 | + | if l.discoveryCycle == 0 { | |
| 456 | + | l.discoveryCycle++ | |
| 457 | + | } | |
| 458 | + | return l.discoveryCycle | |
| 459 | + | } | |
| 460 | + | ||
| 461 | + | func (l *routeLedger) discoveryCycleCurrent(cycle uint64) bool { | |
| 462 | + | if cycle == 0 { | |
| 463 | + | return false | |
| 464 | + | } | |
| 465 | + | l.discoveryMu.Lock() | |
| 466 | + | defer l.discoveryMu.Unlock() | |
| 467 | + | return l.discoveryCycle == cycle | |
| 468 | + | } | |
| @@ -0,0 +1,468 @@ | |||||
| 1 | + | package client | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "fmt" | |||
| 7 | + | "sync" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/vev/internal/ports" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | const maxRouteLedgerEntries = ports.RouteSnapshotMaxEntries | |||
| 13 | + | ||||
| 14 | + | var ( | |||
| 15 | + | errRouteResumeUnavailable = errors.New("route resume unavailable") | |||
| 16 | + | errRouteStaleSelection = errors.New("route selection is stale") | |||
| 17 | + | errRouteTargetChanged = errors.New("route target changed") | |||
| 18 | + | ) | |||
| 19 | + | ||||
| 20 | + | // routeKey is process-local identity. It is never derived from a daemon | |||
| 21 | + | // lifecycle ID and is not exposed outside the client ledger. | |||
| 22 | + | type routeKey uint64 | |||
| 23 | + | ||||
| 24 | + | type routeGeneration uint64 | |||
| 25 | + | ||||
| 26 | + | type routeIdentity struct { | |||
| 27 | + | key routeKey | |||
| 28 | + | generation routeGeneration | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | func (i routeIdentity) empty() bool { return i.key == 0 && i.generation == 0 } | |||
| 32 | + | ||||
| 33 | + | func (i routeIdentity) wire() ports.RouteRef { | |||
| 34 | + | return ports.RouteRef{Key: uint64(i.key), Generation: uint64(i.generation)} | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | func identityFromWire(ref ports.RouteRef) routeIdentity { | |||
| 38 | + | return routeIdentity{key: routeKey(ref.Key), generation: routeGeneration(ref.Generation)} | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | // routePresentation contains only immutable display data. Connection and | |||
| 42 | + | // attach capabilities live in routeRecord beside it and are never copied into | |||
| 43 | + | // a RecentRouteSnapshot. | |||
| 44 | + | type routePresentation struct { | |||
| 45 | + | name string | |||
| 46 | + | hostLabel string | |||
| 47 | + | kind ports.RouteKind | |||
| 48 | + | ephemeral bool | |||
| 49 | + | attention bool | |||
| 50 | + | reachability ports.RouteReachability | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | type routeCandidate struct { | |||
| 54 | + | origin ports.RouteOrigin | |||
| 55 | + | target ports.ExactSessionTarget | |||
| 56 | + | presentation routePresentation | |||
| 57 | + | dialer ports.Dialer | |||
| 58 | + | request AttachRequest | |||
| 59 | + | resumeToken uint64 | |||
| 60 | + | home bool | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | type routeRecord struct { | |||
| 64 | + | identity routeIdentity | |||
| 65 | + | origin ports.RouteOrigin | |||
| 66 | + | target ports.ExactSessionTarget | |||
| 67 | + | presentation routePresentation | |||
| 68 | + | dialer ports.Dialer | |||
| 69 | + | request AttachRequest | |||
| 70 | + | resumeToken uint64 | |||
| 71 | + | home bool | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | // routeLedger owns client-global route identity/history for one client | |||
| 75 | + | // process. Its lock protects both the bounded record set and active/previous | |||
| 76 | + | // references; snapshots are copied while holding the lock and are immutable | |||
| 77 | + | // after publication. | |||
| 78 | + | type routeLedger struct { | |||
| 79 | + | mu sync.RWMutex | |||
| 80 | + | nextKey routeKey | |||
| 81 | + | generation routeGeneration | |||
| 82 | + | entries []routeRecord | |||
| 83 | + | active routeIdentity | |||
| 84 | + | previous routeIdentity | |||
| 85 | + | home routeIdentity | |||
| 86 | + | ||||
| 87 | + | discoveryMu sync.Mutex | |||
| 88 | + | discoveryCycle uint64 | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | func newRouteLedger() *routeLedger { return &routeLedger{} } | |||
| 92 | + | ||||
| 93 | + | func cloneCommittedIdentity(identity *ports.CommittedRouteIdentity) *ports.CommittedRouteIdentity { | |||
| 94 | + | if identity == nil { | |||
| 95 | + | return nil | |||
| 96 | + | } | |||
| 97 | + | copy := *identity | |||
| 98 | + | return © | |||
| 99 | + | } | |||
| 100 | + | ||||
| 101 | + | func routeCandidateForAttach(request AttachRequest, identity ports.CommittedRouteIdentity, dialer ports.Dialer, resumeToken uint64, home bool) routeCandidate { | |||
| 102 | + | origin := normalizeRouteOrigin(request.Origin, request.Remote) | |||
| 103 | + | kind := ports.RouteKindLocal | |||
| 104 | + | hostLabel := "" | |||
| 105 | + | if request.Remote || request.RemoteTarget != nil || origin != ports.RouteOriginLocal { | |||
| 106 | + | kind = ports.RouteKindRemote | |||
| 107 | + | if request.RemoteTarget != nil { | |||
| 108 | + | hostLabel = request.RemoteTarget.DisplayOrigin | |||
| 109 | + | } | |||
| 110 | + | } | |||
| 111 | + | request = cloneAttachRequest(request) | |||
| 112 | + | request.Origin = origin | |||
| 113 | + | request.SessionName = identity.Target.SessionName | |||
| 114 | + | request.ExactTarget = &identity.Target | |||
| 115 | + | return routeCandidate{ | |||
| 116 | + | origin: origin, | |||
| 117 | + | target: identity.Target, | |||
| 118 | + | presentation: routePresentation{ | |||
| 119 | + | name: identity.Target.SessionName, | |||
| 120 | + | hostLabel: hostLabel, | |||
| 121 | + | kind: kind, | |||
| 122 | + | ephemeral: identity.Ephemeral, | |||
| 123 | + | reachability: ports.RouteReachabilityReachable, | |||
| 124 | + | }, | |||
| 125 | + | dialer: dialer, | |||
| 126 | + | request: request, | |||
| 127 | + | resumeToken: resumeToken, | |||
| 128 | + | home: home, | |||
| 129 | + | } | |||
| 130 | + | } | |||
| 131 | + | ||||
| 132 | + | func normalizeRouteOrigin(origin ports.RouteOrigin, remote bool) ports.RouteOrigin { | |||
| 133 | + | if origin != 0 { | |||
| 134 | + | return origin | |||
| 135 | + | } | |||
| 136 | + | if remote { | |||
| 137 | + | return ports.RouteOriginRemote | |||
| 138 | + | } | |||
| 139 | + | return ports.RouteOriginLocal | |||
| 140 | + | } | |||
| 141 | + | ||||
| 142 | + | func validateRoutePresentation(p routePresentation) error { | |||
| 143 | + | if p.name == "" { | |||
| 144 | + | return errors.New("route name is empty") | |||
| 145 | + | } | |||
| 146 | + | if len(p.name) > ports.RouteLabelMaxBytes || len(p.hostLabel) > ports.RouteLabelMaxBytes { | |||
| 147 | + | return errors.New("route label is too long") | |||
| 148 | + | } | |||
| 149 | + | if err := p.kind.Validate(); err != nil { | |||
| 150 | + | return err | |||
| 151 | + | } | |||
| 152 | + | if err := p.reachability.Validate(); err != nil { | |||
| 153 | + | return err | |||
| 154 | + | } | |||
| 155 | + | return nil | |||
| 156 | + | } | |||
| 157 | + | ||||
| 158 | + | func (l *routeLedger) nextIdentityLocked() (routeIdentity, error) { | |||
| 159 | + | if l.nextKey == ^routeKey(0) || l.generation == ^routeGeneration(0) { | |||
| 160 | + | return routeIdentity{}, errors.New("route ledger identity space exhausted") | |||
| 161 | + | } | |||
| 162 | + | l.nextKey++ | |||
| 163 | + | l.generation++ | |||
| 164 | + | return routeIdentity{key: l.nextKey, generation: l.generation}, nil | |||
| 165 | + | } | |||
| 166 | + | ||||
| 167 | + | func (l *routeLedger) findByTargetLocked(origin ports.RouteOrigin, target ports.ExactSessionTarget) int { | |||
| 168 | + | for i := range l.entries { | |||
| 169 | + | entry := l.entries[i] | |||
| 170 | + | if entry.origin == origin && entry.target == target { | |||
| 171 | + | return i | |||
| 172 | + | } | |||
| 173 | + | } | |||
| 174 | + | return -1 | |||
| 175 | + | } | |||
| 176 | + | ||||
| 177 | + | func (l *routeLedger) moveToFrontLocked(index int, entry routeRecord) { | |||
| 178 | + | if index < 0 { | |||
| 179 | + | l.entries = append([]routeRecord{entry}, l.entries...) | |||
| 180 | + | return | |||
| 181 | + | } | |||
| 182 | + | l.entries = append(l.entries[:index], l.entries[index+1:]...) | |||
| 183 | + | l.entries = append([]routeRecord{entry}, l.entries...) | |||
| 184 | + | } | |||
| 185 | + | ||||
| 186 | + | func (l *routeLedger) removeAtLocked(index int) { | |||
| 187 | + | if index < 0 || index >= len(l.entries) { | |||
| 188 | + | return | |||
| 189 | + | } | |||
| 190 | + | removed := l.entries[index].identity | |||
| 191 | + | l.entries = append(l.entries[:index], l.entries[index+1:]...) | |||
| 192 | + | for _, ref := range []*routeIdentity{&l.active, &l.previous, &l.home} { | |||
| 193 | + | if *ref == removed { | |||
| 194 | + | *ref = routeIdentity{} | |||
| 195 | + | } | |||
| 196 | + | } | |||
| 197 | + | } | |||
| 198 | + | ||||
| 199 | + | func (l *routeLedger) evictLocked() { | |||
| 200 | + | for len(l.entries) > maxRouteLedgerEntries { | |||
| 201 | + | index := len(l.entries) - 1 | |||
| 202 | + | for i := len(l.entries) - 1; i >= 0; i-- { | |||
| 203 | + | identity := l.entries[i].identity | |||
| 204 | + | if identity != l.active && identity != l.previous && identity != l.home { | |||
| 205 | + | index = i | |||
| 206 | + | break | |||
| 207 | + | } | |||
| 208 | + | } | |||
| 209 | + | l.removeAtLocked(index) | |||
| 210 | + | } | |||
| 211 | + | } | |||
| 212 | + | ||||
| 213 | + | func (l *routeLedger) commit(candidate routeCandidate) (routeIdentity, error) { | |||
| 214 | + | if err := candidate.origin.Validate(); err != nil { | |||
| 215 | + | return routeIdentity{}, err | |||
| 216 | + | } | |||
| 217 | + | if err := candidate.target.Validate(); err != nil { | |||
| 218 | + | return routeIdentity{}, err | |||
| 219 | + | } | |||
| 220 | + | if err := validateRoutePresentation(candidate.presentation); err != nil { | |||
| 221 | + | return routeIdentity{}, err | |||
| 222 | + | } | |||
| 223 | + | candidate.request = cloneAttachRequest(candidate.request) | |||
| 224 | + | candidate.request.Origin = candidate.origin | |||
| 225 | + | candidate.request.ExactTarget = &candidate.target | |||
| 226 | + | ||||
| 227 | + | l.mu.Lock() | |||
| 228 | + | defer l.mu.Unlock() | |||
| 229 | + | return l.commitLocked(candidate) | |||
| 230 | + | } | |||
| 231 | + | ||||
| 232 | + | func (l *routeLedger) commitLocked(candidate routeCandidate) (routeIdentity, error) { | |||
| 233 | + | identity, err := l.nextIdentityLocked() | |||
| 234 | + | if err != nil { | |||
| 235 | + | return routeIdentity{}, err | |||
| 236 | + | } | |||
| 237 | + | index := l.findByTargetLocked(candidate.origin, candidate.target) | |||
| 238 | + | if index >= 0 { | |||
| 239 | + | identity.key = l.entries[index].identity.key | |||
| 240 | + | } | |||
| 241 | + | entry := routeRecord{ | |||
| 242 | + | identity: identity, | |||
| 243 | + | origin: candidate.origin, | |||
| 244 | + | target: candidate.target, | |||
| 245 | + | presentation: candidate.presentation, | |||
| 246 | + | dialer: candidate.dialer, | |||
| 247 | + | request: candidate.request, | |||
| 248 | + | resumeToken: candidate.resumeToken, | |||
| 249 | + | home: candidate.home, | |||
| 250 | + | } | |||
| 251 | + | oldActive := l.active | |||
| 252 | + | l.moveToFrontLocked(index, entry) | |||
| 253 | + | if !oldActive.empty() && oldActive.key != identity.key { | |||
| 254 | + | l.previous = oldActive | |||
| 255 | + | } | |||
| 256 | + | if l.previous.key == identity.key { | |||
| 257 | + | l.previous = routeIdentity{} | |||
| 258 | + | } | |||
| 259 | + | l.active = identity | |||
| 260 | + | if candidate.home { | |||
| 261 | + | l.home = identity | |||
| 262 | + | } | |||
| 263 | + | if l.home.key == identity.key { | |||
| 264 | + | l.home.generation = identity.generation | |||
| 265 | + | } | |||
| 266 | + | if l.previous.key != 0 { | |||
| 267 | + | if previousIndex := l.indexByIdentityLocked(l.previous); previousIndex < 0 { | |||
| 268 | + | l.previous = routeIdentity{} | |||
| 269 | + | } | |||
| 270 | + | } | |||
| 271 | + | l.evictLocked() | |||
| 272 | + | return identity, nil | |||
| 273 | + | } | |||
| 274 | + | ||||
| 275 | + | func (l *routeLedger) indexByIdentityLocked(identity routeIdentity) int { | |||
| 276 | + | for i := range l.entries { | |||
| 277 | + | if l.entries[i].identity == identity { | |||
| 278 | + | return i | |||
| 279 | + | } | |||
| 280 | + | } | |||
| 281 | + | return -1 | |||
| 282 | + | } | |||
| 283 | + | ||||
| 284 | + | func (l *routeLedger) lookup(ref ports.RouteRef) (routeRecord, bool) { | |||
| 285 | + | identity := identityFromWire(ref) | |||
| 286 | + | if identity.empty() { | |||
| 287 | + | return routeRecord{}, false | |||
| 288 | + | } | |||
| 289 | + | l.mu.RLock() | |||
| 290 | + | defer l.mu.RUnlock() | |||
| 291 | + | for _, entry := range l.entries { | |||
| 292 | + | if entry.identity == identity { | |||
| 293 | + | return cloneRouteRecord(entry), true | |||
| 294 | + | } | |||
| 295 | + | } | |||
| 296 | + | return routeRecord{}, false | |||
| 297 | + | } | |||
| 298 | + | ||||
| 299 | + | func cloneRouteRecord(entry routeRecord) routeRecord { | |||
| 300 | + | entry.request = cloneAttachRequest(entry.request) | |||
| 301 | + | return entry | |||
| 302 | + | } | |||
| 303 | + | ||||
| 304 | + | func (l *routeLedger) snapshot() ports.RecentRouteSnapshot { | |||
| 305 | + | l.mu.RLock() | |||
| 306 | + | defer l.mu.RUnlock() | |||
| 307 | + | ||||
| 308 | + | snapshot := ports.RecentRouteSnapshot{ | |||
| 309 | + | Generation: uint64(l.generation), | |||
| 310 | + | Active: l.active.wire(), | |||
| 311 | + | Previous: l.previous.wire(), | |||
| 312 | + | Home: l.home.wire(), | |||
| 313 | + | } | |||
| 314 | + | if len(l.entries) == 0 { | |||
| 315 | + | return snapshot | |||
| 316 | + | } | |||
| 317 | + | snapshot.Entries = make([]ports.RecentRouteEntry, 0, len(l.entries)) | |||
| 318 | + | for _, entry := range l.entries { | |||
| 319 | + | snapshot.Entries = append(snapshot.Entries, ports.RecentRouteEntry{ | |||
| 320 | + | Key: uint64(entry.identity.key), | |||
| 321 | + | Generation: uint64(entry.identity.generation), | |||
| 322 | + | Name: entry.presentation.name, | |||
| 323 | + | HostLabel: entry.presentation.hostLabel, | |||
| 324 | + | Kind: entry.presentation.kind, | |||
| 325 | + | Ephemeral: entry.presentation.ephemeral, | |||
| 326 | + | Attention: entry.presentation.attention, | |||
| 327 | + | Reachability: entry.presentation.reachability, | |||
| 328 | + | }) | |||
| 329 | + | } | |||
| 330 | + | return snapshot | |||
| 331 | + | } | |||
| 332 | + | ||||
| 333 | + | func (l *routeLedger) activeRef() ports.RouteRef { | |||
| 334 | + | l.mu.RLock() | |||
| 335 | + | defer l.mu.RUnlock() | |||
| 336 | + | return l.active.wire() | |||
| 337 | + | } | |||
| 338 | + | ||||
| 339 | + | func (l *routeLedger) previousRef() ports.RouteRef { | |||
| 340 | + | l.mu.RLock() | |||
| 341 | + | defer l.mu.RUnlock() | |||
| 342 | + | return l.previous.wire() | |||
| 343 | + | } | |||
| 344 | + | ||||
| 345 | + | func (l *routeLedger) homeRef() ports.RouteRef { | |||
| 346 | + | l.mu.RLock() | |||
| 347 | + | defer l.mu.RUnlock() | |||
| 348 | + | return l.home.wire() | |||
| 349 | + | } | |||
| 350 | + | ||||
| 351 | + | // routeTransitionPlan is the private lookup result used by navigation. It | |||
| 352 | + | // preserves the original origin and exact target while leaving display labels | |||
| 353 | + | // and transport capabilities separate. | |||
| 354 | + | type routeTransitionPlan struct { | |||
| 355 | + | identity routeIdentity | |||
| 356 | + | record routeRecord | |||
| 357 | + | origin ports.RouteOrigin | |||
| 358 | + | target ports.ExactSessionTarget | |||
| 359 | + | } | |||
| 360 | + | ||||
| 361 | + | type routeTransitionCommit struct { | |||
| 362 | + | identity ports.CommittedRouteIdentity | |||
| 363 | + | presentation routePresentation | |||
| 364 | + | dialer ports.Dialer | |||
| 365 | + | resumeToken uint64 | |||
| 366 | + | } | |||
| 367 | + | ||||
| 368 | + | type routeTransitionConnector interface { | |||
| 369 | + | Resume(context.Context, routeRecord) (routeTransitionCommit, error) | |||
| 370 | + | Attach(context.Context, routeRecord) (routeTransitionCommit, error) | |||
| 371 | + | } | |||
| 372 | + | ||||
| 373 | + | // navigate resolves a captured key/generation, attempts resume before exact | |||
| 374 | + | // attach, and commits only after the connector returns a matching identity. | |||
| 375 | + | // A failed transition leaves active/previous history unchanged. | |||
| 376 | + | func (l *routeLedger) navigate(ctx context.Context, action ports.RouteNavigationAction, connector routeTransitionConnector) error { | |||
| 377 | + | if action.Key == 0 || action.Generation == 0 { | |||
| 378 | + | return errRouteStaleSelection | |||
| 379 | + | } | |||
| 380 | + | if connector == nil { | |||
| 381 | + | return errors.New("route transition connector is nil") | |||
| 382 | + | } | |||
| 383 | + | if ctx == nil { | |||
| 384 | + | ctx = context.Background() | |||
| 385 | + | } | |||
| 386 | + | selected := ports.RouteRef{Key: action.Key, Generation: action.Generation} | |||
| 387 | + | record, ok := l.lookup(selected) | |||
| 388 | + | if !ok { | |||
| 389 | + | return errRouteStaleSelection | |||
| 390 | + | } | |||
| 391 | + | plan := routeTransitionPlan{ | |||
| 392 | + | identity: identityFromWire(selected), | |||
| 393 | + | record: record, | |||
| 394 | + | origin: record.origin, | |||
| 395 | + | target: record.target, | |||
| 396 | + | } | |||
| 397 | + | ||||
| 398 | + | var commit routeTransitionCommit | |||
| 399 | + | var err error | |||
| 400 | + | if record.resumeToken != 0 { | |||
| 401 | + | commit, err = connector.Resume(ctx, record) | |||
| 402 | + | if errors.Is(err, errRouteResumeUnavailable) { | |||
| 403 | + | commit, err = connector.Attach(ctx, record) | |||
| 404 | + | } | |||
| 405 | + | } else { | |||
| 406 | + | commit, err = connector.Attach(ctx, record) | |||
| 407 | + | } | |||
| 408 | + | if err != nil { | |||
| 409 | + | return err | |||
| 410 | + | } | |||
| 411 | + | if err := commit.identity.Validate(); err != nil { | |||
| 412 | + | return fmt.Errorf("invalid committed route identity: %w", err) | |||
| 413 | + | } | |||
| 414 | + | if commit.identity.Target != plan.target { | |||
| 415 | + | return errRouteTargetChanged | |||
| 416 | + | } | |||
| 417 | + | if err := validateRoutePresentation(commit.presentation); err != nil { | |||
| 418 | + | return err | |||
| 419 | + | } | |||
| 420 | + | ||||
| 421 | + | candidate := routeCandidate{ | |||
| 422 | + | origin: plan.origin, | |||
| 423 | + | target: plan.target, | |||
| 424 | + | presentation: commit.presentation, | |||
| 425 | + | dialer: commit.dialer, | |||
| 426 | + | request: cloneAttachRequest(plan.record.request), | |||
| 427 | + | resumeToken: commit.resumeToken, | |||
| 428 | + | home: plan.record.home, | |||
| 429 | + | } | |||
| 430 | + | candidate.request.Origin = plan.origin | |||
| 431 | + | candidate.request.ExactTarget = &candidate.target | |||
| 432 | + | return l.commitTransition(plan.identity, candidate) | |||
| 433 | + | } | |||
| 434 | + | ||||
| 435 | + | func (l *routeLedger) commitTransition(selected routeIdentity, candidate routeCandidate) error { | |||
| 436 | + | l.mu.Lock() | |||
| 437 | + | defer l.mu.Unlock() | |||
| 438 | + | index := l.indexByIdentityLocked(selected) | |||
| 439 | + | if index < 0 { | |||
| 440 | + | return errRouteStaleSelection | |||
| 441 | + | } | |||
| 442 | + | if _, err := l.commitLocked(candidate); err != nil { | |||
| 443 | + | return err | |||
| 444 | + | } | |||
| 445 | + | return nil | |||
| 446 | + | } | |||
| 447 | + | ||||
| 448 | + | // beginDiscoveryCycle and discoveryCycleCurrent deliberately use a lock | |||
| 449 | + | // separate from route history. A stale discovery result cannot clear or commit | |||
| 450 | + | // unrelated navigation history. | |||
| 451 | + | func (l *routeLedger) beginDiscoveryCycle() uint64 { | |||
| 452 | + | l.discoveryMu.Lock() | |||
| 453 | + | defer l.discoveryMu.Unlock() | |||
| 454 | + | l.discoveryCycle++ | |||
| 455 | + | if l.discoveryCycle == 0 { | |||
| 456 | + | l.discoveryCycle++ | |||
| 457 | + | } | |||
| 458 | + | return l.discoveryCycle | |||
| 459 | + | } | |||
| 460 | + | ||||
| 461 | + | func (l *routeLedger) discoveryCycleCurrent(cycle uint64) bool { | |||
| 462 | + | if cycle == 0 { | |||
| 463 | + | return false | |||
| 464 | + | } | |||
| 465 | + | l.discoveryMu.Lock() | |||
| 466 | + | defer l.discoveryMu.Unlock() | |||
| 467 | + | return l.discoveryCycle == cycle | |||
| 468 | + | } | |||
A internal/usecase/client/routes_test.go +194 -0
| @@ -0,0 +1,194 @@ | |||
| 1 | + | package client | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "sync" | |
| 7 | + | "testing" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/vev/internal/domain" | |
| 10 | + | "github.com/bnema/vev/internal/ports" | |
| 11 | + | "github.com/stretchr/testify/require" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | func routeTestTarget(index byte) ports.ExactSessionTarget { | |
| 15 | + | return ports.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{index + 1}, SessionName: "session-" + string(rune('a'+index%26)) + string(rune('0'+index/26))} | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | func routeTestCandidate(index byte, origin ports.RouteOrigin) routeCandidate { | |
| 19 | + | target := routeTestTarget(index) | |
| 20 | + | return routeCandidate{ | |
| 21 | + | origin: origin, | |
| 22 | + | target: target, | |
| 23 | + | presentation: routePresentation{ | |
| 24 | + | name: target.SessionName, | |
| 25 | + | kind: ports.RouteKindLocal, | |
| 26 | + | reachability: ports.RouteReachabilityReachable, | |
| 27 | + | }, | |
| 28 | + | request: AttachRequest{ | |
| 29 | + | Intent: ports.IntentAttach, | |
| 30 | + | SessionName: target.SessionName, | |
| 31 | + | Origin: origin, | |
| 32 | + | }, | |
| 33 | + | } | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | func TestRouteLedgerBoundsAndImmutableSnapshot(t *testing.T) { | |
| 37 | + | ledger := newRouteLedger() | |
| 38 | + | var keys []routeKey | |
| 39 | + | for i := byte(0); i < maxRouteLedgerEntries+8; i++ { | |
| 40 | + | candidate := routeTestCandidate(i, ports.RouteOriginLocal) | |
| 41 | + | candidate.home = i == 0 | |
| 42 | + | identity, err := ledger.commit(candidate) | |
| 43 | + | require.NoError(t, err) | |
| 44 | + | keys = append(keys, identity.key) | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | snapshot := ledger.snapshot() | |
| 48 | + | require.Len(t, snapshot.Entries, maxRouteLedgerEntries) | |
| 49 | + | require.Equal(t, uint64(keys[len(keys)-1]), snapshot.Active.Key) | |
| 50 | + | require.Equal(t, uint64(keys[len(keys)-2]), snapshot.Previous.Key) | |
| 51 | + | require.Equal(t, uint64(keys[0]), snapshot.Home.Key, "home is retained when older entries are evicted") | |
| 52 | + | ||
| 53 | + | for i := 1; i < len(snapshot.Entries); i++ { | |
| 54 | + | require.Greater(t, snapshot.Entries[i-1].Generation, snapshot.Entries[i].Generation) | |
| 55 | + | } | |
| 56 | + | originalName := snapshot.Entries[0].Name | |
| 57 | + | snapshot.Entries[0].Name = "mutated" | |
| 58 | + | fresh := ledger.snapshot() | |
| 59 | + | require.Equal(t, originalName, fresh.Entries[0].Name) | |
| 60 | + | ||
| 61 | + | old := ledger.activeRef() | |
| 62 | + | _, err := ledger.commit(routeTestCandidate(maxRouteLedgerEntries+9, ports.RouteOriginLocal)) | |
| 63 | + | require.NoError(t, err) | |
| 64 | + | require.NotEqual(t, old, ledger.activeRef()) | |
| 65 | + | require.Equal(t, old, ledger.previousRef(), "the old active route becomes the previous route") | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | func TestRouteLedgerUpsertChangesGenerationWithoutChangingOpaqueKey(t *testing.T) { | |
| 69 | + | ledger := newRouteLedger() | |
| 70 | + | candidate := routeTestCandidate(1, ports.RouteOriginRemote) | |
| 71 | + | first, err := ledger.commit(candidate) | |
| 72 | + | require.NoError(t, err) | |
| 73 | + | ||
| 74 | + | candidate.presentation.attention = true | |
| 75 | + | second, err := ledger.commit(candidate) | |
| 76 | + | require.NoError(t, err) | |
| 77 | + | require.Equal(t, first.key, second.key) | |
| 78 | + | require.Greater(t, second.generation, first.generation) | |
| 79 | + | _, ok := ledger.lookup(first.wire()) | |
| 80 | + | require.False(t, ok, "old generation must not resolve") | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | func TestRouteLedgerDiscoveryCyclesAreIndependent(t *testing.T) { | |
| 84 | + | ledger := newRouteLedger() | |
| 85 | + | first := ledger.beginDiscoveryCycle() | |
| 86 | + | second := ledger.beginDiscoveryCycle() | |
| 87 | + | ||
| 88 | + | require.NotEqual(t, first, second) | |
| 89 | + | require.False(t, ledger.discoveryCycleCurrent(first)) | |
| 90 | + | require.True(t, ledger.discoveryCycleCurrent(second)) | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | type routeTestConnector struct { | |
| 94 | + | resumeErr error | |
| 95 | + | attachErr error | |
| 96 | + | resumeCalls int | |
| 97 | + | attachCalls int | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | func (c *routeTestConnector) Resume(context.Context, routeRecord) (routeTransitionCommit, error) { | |
| 101 | + | c.resumeCalls++ | |
| 102 | + | if c.resumeErr != nil { | |
| 103 | + | return routeTransitionCommit{}, c.resumeErr | |
| 104 | + | } | |
| 105 | + | return routeTransitionCommit{}, errors.New("unexpected resume success") | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | func (c *routeTestConnector) Attach(_ context.Context, record routeRecord) (routeTransitionCommit, error) { | |
| 109 | + | c.attachCalls++ | |
| 110 | + | if c.attachErr != nil { | |
| 111 | + | return routeTransitionCommit{}, c.attachErr | |
| 112 | + | } | |
| 113 | + | return routeTransitionCommit{ | |
| 114 | + | identity: ports.CommittedRouteIdentity{Target: record.target}, | |
| 115 | + | presentation: routePresentation{ | |
| 116 | + | name: record.presentation.name, | |
| 117 | + | kind: record.presentation.kind, | |
| 118 | + | reachability: ports.RouteReachabilityReachable, | |
| 119 | + | }, | |
| 120 | + | resumeToken: 123, | |
| 121 | + | }, nil | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | func TestRouteLedgerNavigateResumesThenFallsBackAndRestoresOrigin(t *testing.T) { | |
| 125 | + | ledger := newRouteLedger() | |
| 126 | + | candidate := routeTestCandidate(2, ports.RouteOriginDiscovery) | |
| 127 | + | candidate.resumeToken = 77 | |
| 128 | + | identity, err := ledger.commit(candidate) | |
| 129 | + | require.NoError(t, err) | |
| 130 | + | ||
| 131 | + | connector := &routeTestConnector{resumeErr: errRouteResumeUnavailable} | |
| 132 | + | err = ledger.navigate(context.Background(), ports.RouteNavigationAction{Key: uint64(identity.key), Generation: uint64(identity.generation)}, connector) | |
| 133 | + | require.NoError(t, err) | |
| 134 | + | require.Equal(t, 1, connector.resumeCalls) | |
| 135 | + | require.Equal(t, 1, connector.attachCalls) | |
| 136 | + | ||
| 137 | + | active, ok := ledger.lookup(ledger.activeRef()) | |
| 138 | + | require.True(t, ok) | |
| 139 | + | require.Equal(t, ports.RouteOriginDiscovery, active.origin) | |
| 140 | + | require.Equal(t, ports.RouteOriginDiscovery, active.request.Origin) | |
| 141 | + | require.Equal(t, candidate.target, active.target) | |
| 142 | + | require.Greater(t, active.identity.generation, identity.generation) | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | func TestRouteLedgerNavigateIsTransactionalOnFailureOrTargetChange(t *testing.T) { | |
| 146 | + | ledger := newRouteLedger() | |
| 147 | + | identity, err := ledger.commit(routeTestCandidate(3, ports.RouteOriginLocal)) | |
| 148 | + | require.NoError(t, err) | |
| 149 | + | before := ledger.snapshot() | |
| 150 | + | ||
| 151 | + | connector := &routeTestConnector{attachErr: errors.New("offline")} | |
| 152 | + | err = ledger.navigate(context.Background(), identity.wireNavigationAction(), connector) | |
| 153 | + | require.Error(t, err) | |
| 154 | + | require.Equal(t, before, ledger.snapshot()) | |
| 155 | + | ||
| 156 | + | connector = &routeTestConnector{} | |
| 157 | + | connector.attachErr = nil | |
| 158 | + | // Force a mismatched committed target through a small wrapper. | |
| 159 | + | mismatch := mismatchingRouteConnector{routeTestConnector: connector} | |
| 160 | + | err = ledger.navigate(context.Background(), identity.wireNavigationAction(), mismatch) | |
| 161 | + | require.ErrorIs(t, err, errRouteTargetChanged) | |
| 162 | + | require.Equal(t, before, ledger.snapshot()) | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | type mismatchingRouteConnector struct{ *routeTestConnector } | |
| 166 | + | ||
| 167 | + | func (c mismatchingRouteConnector) Attach(_ context.Context, record routeRecord) (routeTransitionCommit, error) { | |
| 168 | + | return routeTransitionCommit{ | |
| 169 | + | identity: ports.CommittedRouteIdentity{Target: routeTestTarget(99)}, | |
| 170 | + | presentation: routePresentation{name: record.presentation.name, kind: record.presentation.kind}, | |
| 171 | + | }, nil | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | func (i routeIdentity) wireNavigationAction() ports.RouteNavigationAction { | |
| 175 | + | return ports.RouteNavigationAction{Key: uint64(i.key), Generation: uint64(i.generation)} | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | func TestRouteLedgerConcurrentSnapshotsAndCommits(t *testing.T) { | |
| 179 | + | ledger := newRouteLedger() | |
| 180 | + | var wg sync.WaitGroup | |
| 181 | + | for worker := byte(0); worker < 4; worker++ { | |
| 182 | + | worker := worker | |
| 183 | + | wg.Add(1) | |
| 184 | + | go func() { | |
| 185 | + | defer wg.Done() | |
| 186 | + | for i := byte(0); i < 20; i++ { | |
| 187 | + | _, _ = ledger.commit(routeTestCandidate(worker*20+i, ports.RouteOriginLocal)) | |
| 188 | + | _ = ledger.snapshot() | |
| 189 | + | } | |
| 190 | + | }() | |
| 191 | + | } | |
| 192 | + | wg.Wait() | |
| 193 | + | require.LessOrEqual(t, len(ledger.snapshot().Entries), maxRouteLedgerEntries) | |
| 194 | + | } | |
| @@ -0,0 +1,194 @@ | |||||
| 1 | + | package client | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "sync" | |||
| 7 | + | "testing" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/vev/internal/domain" | |||
| 10 | + | "github.com/bnema/vev/internal/ports" | |||
| 11 | + | "github.com/stretchr/testify/require" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | func routeTestTarget(index byte) ports.ExactSessionTarget { | |||
| 15 | + | return ports.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{index + 1}, SessionName: "session-" + string(rune('a'+index%26)) + string(rune('0'+index/26))} | |||
| 16 | + | } | |||
| 17 | + | ||||
| 18 | + | func routeTestCandidate(index byte, origin ports.RouteOrigin) routeCandidate { | |||
| 19 | + | target := routeTestTarget(index) | |||
| 20 | + | return routeCandidate{ | |||
| 21 | + | origin: origin, | |||
| 22 | + | target: target, | |||
| 23 | + | presentation: routePresentation{ | |||
| 24 | + | name: target.SessionName, | |||
| 25 | + | kind: ports.RouteKindLocal, | |||
| 26 | + | reachability: ports.RouteReachabilityReachable, | |||
| 27 | + | }, | |||
| 28 | + | request: AttachRequest{ | |||
| 29 | + | Intent: ports.IntentAttach, | |||
| 30 | + | SessionName: target.SessionName, | |||
| 31 | + | Origin: origin, | |||
| 32 | + | }, | |||
| 33 | + | } | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | func TestRouteLedgerBoundsAndImmutableSnapshot(t *testing.T) { | |||
| 37 | + | ledger := newRouteLedger() | |||
| 38 | + | var keys []routeKey | |||
| 39 | + | for i := byte(0); i < maxRouteLedgerEntries+8; i++ { | |||
| 40 | + | candidate := routeTestCandidate(i, ports.RouteOriginLocal) | |||
| 41 | + | candidate.home = i == 0 | |||
| 42 | + | identity, err := ledger.commit(candidate) | |||
| 43 | + | require.NoError(t, err) | |||
| 44 | + | keys = append(keys, identity.key) | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | snapshot := ledger.snapshot() | |||
| 48 | + | require.Len(t, snapshot.Entries, maxRouteLedgerEntries) | |||
| 49 | + | require.Equal(t, uint64(keys[len(keys)-1]), snapshot.Active.Key) | |||
| 50 | + | require.Equal(t, uint64(keys[len(keys)-2]), snapshot.Previous.Key) | |||
| 51 | + | require.Equal(t, uint64(keys[0]), snapshot.Home.Key, "home is retained when older entries are evicted") | |||
| 52 | + | ||||
| 53 | + | for i := 1; i < len(snapshot.Entries); i++ { | |||
| 54 | + | require.Greater(t, snapshot.Entries[i-1].Generation, snapshot.Entries[i].Generation) | |||
| 55 | + | } | |||
| 56 | + | originalName := snapshot.Entries[0].Name | |||
| 57 | + | snapshot.Entries[0].Name = "mutated" | |||
| 58 | + | fresh := ledger.snapshot() | |||
| 59 | + | require.Equal(t, originalName, fresh.Entries[0].Name) | |||
| 60 | + | ||||
| 61 | + | old := ledger.activeRef() | |||
| 62 | + | _, err := ledger.commit(routeTestCandidate(maxRouteLedgerEntries+9, ports.RouteOriginLocal)) | |||
| 63 | + | require.NoError(t, err) | |||
| 64 | + | require.NotEqual(t, old, ledger.activeRef()) | |||
| 65 | + | require.Equal(t, old, ledger.previousRef(), "the old active route becomes the previous route") | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | func TestRouteLedgerUpsertChangesGenerationWithoutChangingOpaqueKey(t *testing.T) { | |||
| 69 | + | ledger := newRouteLedger() | |||
| 70 | + | candidate := routeTestCandidate(1, ports.RouteOriginRemote) | |||
| 71 | + | first, err := ledger.commit(candidate) | |||
| 72 | + | require.NoError(t, err) | |||
| 73 | + | ||||
| 74 | + | candidate.presentation.attention = true | |||
| 75 | + | second, err := ledger.commit(candidate) | |||
| 76 | + | require.NoError(t, err) | |||
| 77 | + | require.Equal(t, first.key, second.key) | |||
| 78 | + | require.Greater(t, second.generation, first.generation) | |||
| 79 | + | _, ok := ledger.lookup(first.wire()) | |||
| 80 | + | require.False(t, ok, "old generation must not resolve") | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | func TestRouteLedgerDiscoveryCyclesAreIndependent(t *testing.T) { | |||
| 84 | + | ledger := newRouteLedger() | |||
| 85 | + | first := ledger.beginDiscoveryCycle() | |||
| 86 | + | second := ledger.beginDiscoveryCycle() | |||
| 87 | + | ||||
| 88 | + | require.NotEqual(t, first, second) | |||
| 89 | + | require.False(t, ledger.discoveryCycleCurrent(first)) | |||
| 90 | + | require.True(t, ledger.discoveryCycleCurrent(second)) | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | type routeTestConnector struct { | |||
| 94 | + | resumeErr error | |||
| 95 | + | attachErr error | |||
| 96 | + | resumeCalls int | |||
| 97 | + | attachCalls int | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | func (c *routeTestConnector) Resume(context.Context, routeRecord) (routeTransitionCommit, error) { | |||
| 101 | + | c.resumeCalls++ | |||
| 102 | + | if c.resumeErr != nil { | |||
| 103 | + | return routeTransitionCommit{}, c.resumeErr | |||
| 104 | + | } | |||
| 105 | + | return routeTransitionCommit{}, errors.New("unexpected resume success") | |||
| 106 | + | } | |||
| 107 | + | ||||
| 108 | + | func (c *routeTestConnector) Attach(_ context.Context, record routeRecord) (routeTransitionCommit, error) { | |||
| 109 | + | c.attachCalls++ | |||
| 110 | + | if c.attachErr != nil { | |||
| 111 | + | return routeTransitionCommit{}, c.attachErr | |||
| 112 | + | } | |||
| 113 | + | return routeTransitionCommit{ | |||
| 114 | + | identity: ports.CommittedRouteIdentity{Target: record.target}, | |||
| 115 | + | presentation: routePresentation{ | |||
| 116 | + | name: record.presentation.name, | |||
| 117 | + | kind: record.presentation.kind, | |||
| 118 | + | reachability: ports.RouteReachabilityReachable, | |||
| 119 | + | }, | |||
| 120 | + | resumeToken: 123, | |||
| 121 | + | }, nil | |||
| 122 | + | } | |||
| 123 | + | ||||
| 124 | + | func TestRouteLedgerNavigateResumesThenFallsBackAndRestoresOrigin(t *testing.T) { | |||
| 125 | + | ledger := newRouteLedger() | |||
| 126 | + | candidate := routeTestCandidate(2, ports.RouteOriginDiscovery) | |||
| 127 | + | candidate.resumeToken = 77 | |||
| 128 | + | identity, err := ledger.commit(candidate) | |||
| 129 | + | require.NoError(t, err) | |||
| 130 | + | ||||
| 131 | + | connector := &routeTestConnector{resumeErr: errRouteResumeUnavailable} | |||
| 132 | + | err = ledger.navigate(context.Background(), ports.RouteNavigationAction{Key: uint64(identity.key), Generation: uint64(identity.generation)}, connector) | |||
| 133 | + | require.NoError(t, err) | |||
| 134 | + | require.Equal(t, 1, connector.resumeCalls) | |||
| 135 | + | require.Equal(t, 1, connector.attachCalls) | |||
| 136 | + | ||||
| 137 | + | active, ok := ledger.lookup(ledger.activeRef()) | |||
| 138 | + | require.True(t, ok) | |||
| 139 | + | require.Equal(t, ports.RouteOriginDiscovery, active.origin) | |||
| 140 | + | require.Equal(t, ports.RouteOriginDiscovery, active.request.Origin) | |||
| 141 | + | require.Equal(t, candidate.target, active.target) | |||
| 142 | + | require.Greater(t, active.identity.generation, identity.generation) | |||
| 143 | + | } | |||
| 144 | + | ||||
| 145 | + | func TestRouteLedgerNavigateIsTransactionalOnFailureOrTargetChange(t *testing.T) { | |||
| 146 | + | ledger := newRouteLedger() | |||
| 147 | + | identity, err := ledger.commit(routeTestCandidate(3, ports.RouteOriginLocal)) | |||
| 148 | + | require.NoError(t, err) | |||
| 149 | + | before := ledger.snapshot() | |||
| 150 | + | ||||
| 151 | + | connector := &routeTestConnector{attachErr: errors.New("offline")} | |||
| 152 | + | err = ledger.navigate(context.Background(), identity.wireNavigationAction(), connector) | |||
| 153 | + | require.Error(t, err) | |||
| 154 | + | require.Equal(t, before, ledger.snapshot()) | |||
| 155 | + | ||||
| 156 | + | connector = &routeTestConnector{} | |||
| 157 | + | connector.attachErr = nil | |||
| 158 | + | // Force a mismatched committed target through a small wrapper. | |||
| 159 | + | mismatch := mismatchingRouteConnector{routeTestConnector: connector} | |||
| 160 | + | err = ledger.navigate(context.Background(), identity.wireNavigationAction(), mismatch) | |||
| 161 | + | require.ErrorIs(t, err, errRouteTargetChanged) | |||
| 162 | + | require.Equal(t, before, ledger.snapshot()) | |||
| 163 | + | } | |||
| 164 | + | ||||
| 165 | + | type mismatchingRouteConnector struct{ *routeTestConnector } | |||
| 166 | + | ||||
| 167 | + | func (c mismatchingRouteConnector) Attach(_ context.Context, record routeRecord) (routeTransitionCommit, error) { | |||
| 168 | + | return routeTransitionCommit{ | |||
| 169 | + | identity: ports.CommittedRouteIdentity{Target: routeTestTarget(99)}, | |||
| 170 | + | presentation: routePresentation{name: record.presentation.name, kind: record.presentation.kind}, | |||
| 171 | + | }, nil | |||
| 172 | + | } | |||
| 173 | + | ||||
| 174 | + | func (i routeIdentity) wireNavigationAction() ports.RouteNavigationAction { | |||
| 175 | + | return ports.RouteNavigationAction{Key: uint64(i.key), Generation: uint64(i.generation)} | |||
| 176 | + | } | |||
| 177 | + | ||||
| 178 | + | func TestRouteLedgerConcurrentSnapshotsAndCommits(t *testing.T) { | |||
| 179 | + | ledger := newRouteLedger() | |||
| 180 | + | var wg sync.WaitGroup | |||
| 181 | + | for worker := byte(0); worker < 4; worker++ { | |||
| 182 | + | worker := worker | |||
| 183 | + | wg.Add(1) | |||
| 184 | + | go func() { | |||
| 185 | + | defer wg.Done() | |||
| 186 | + | for i := byte(0); i < 20; i++ { | |||
| 187 | + | _, _ = ledger.commit(routeTestCandidate(worker*20+i, ports.RouteOriginLocal)) | |||
| 188 | + | _ = ledger.snapshot() | |||
| 189 | + | } | |||
| 190 | + | }() | |||
| 191 | + | } | |||
| 192 | + | wg.Wait() | |||
| 193 | + | require.LessOrEqual(t, len(ledger.snapshot().Entries), maxRouteLedgerEntries) | |||
| 194 | + | } | |||
M internal/usecase/client/session_connection.go +4 -0
| @@ -80,6 +80,10 @@ func cloneAttachRequest(request AttachRequest) AttachRequest { | |||
| 80 | 80 | target := *request.RemoteTarget | |
| 81 | 81 | request.RemoteTarget = &target | |
| 82 | 82 | } | |
| 83 | + | if request.ExactTarget != nil { | |
| 84 | + | target := *request.ExactTarget | |
| 85 | + | request.ExactTarget = &target | |
| 86 | + | } | |
| 83 | 87 | return request | |
| 84 | 88 | } | |
| 85 | 89 | ||
| @@ -80,6 +80,10 @@ func cloneAttachRequest(request AttachRequest) AttachRequest { | |||||
| 80 | target := *request.RemoteTarget | 80 | target := *request.RemoteTarget | ||
| 81 | request.RemoteTarget = &target | 81 | request.RemoteTarget = &target | ||
| 82 | } | 82 | } | ||
| 83 | + | if request.ExactTarget != nil { | |||
| 84 | + | target := *request.ExactTarget | |||
| 85 | + | request.ExactTarget = &target | |||
| 86 | + | } | |||
| 83 | return request | 87 | return request | ||
| 84 | } | 88 | } | ||
| 85 | 89 | ||||
M internal/usecase/daemon/daemon.go +28 -0
| @@ -1352,6 +1352,29 @@ func (d *Daemon) waitForTargetRestore(ctx context.Context, name string) error { | |||
| 1352 | 1352 | // route resolves a Hello to a session and a freshly attached client, creating | |
| 1353 | 1353 | // the session for ephemeral/new intents. Direct package callers retain the | |
| 1354 | 1354 | // daemon lifetime context; inbound handshakes use routeWithContext. | |
| 1355 | + | func (d *Daemon) validateExactSessionTarget(ctx context.Context, target ports.ExactSessionTarget) error { | |
| 1356 | + | if err := target.Validate(); err != nil { | |
| 1357 | + | return &protoErr{ports.ErrNoSuchSession, "invalid exact session target"} | |
| 1358 | + | } | |
| 1359 | + | if err := d.waitForTargetRestore(ctx, target.SessionName); err != nil { | |
| 1360 | + | return err | |
| 1361 | + | } | |
| 1362 | + | ||
| 1363 | + | d.mu.Lock() | |
| 1364 | + | defer d.mu.Unlock() | |
| 1365 | + | if sess := d.findByNameLocked(target.SessionName); sess != nil { | |
| 1366 | + | if sess.incarnation != target.LifecycleID { | |
| 1367 | + | return &protoErr{ports.ErrNoSuchSession, "session lifecycle has changed: " + target.SessionName} | |
| 1368 | + | } | |
| 1369 | + | return nil | |
| 1370 | + | } | |
| 1371 | + | stopped, ok := d.stopped[target.SessionName] | |
| 1372 | + | if !ok || stopped.purging || stopped.incarnation != target.LifecycleID { | |
| 1373 | + | return &protoErr{ports.ErrNoSuchSession, "no such session lifecycle: " + target.SessionName} | |
| 1374 | + | } | |
| 1375 | + | return nil | |
| 1376 | + | } | |
| 1377 | + | ||
| 1355 | 1378 | func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedClient, error) { | |
| 1356 | 1379 | ctx := d.serveCtx | |
| 1357 | 1380 | if ctx == nil { | |
| @@ -1378,6 +1401,11 @@ func (d *Daemon) routeWithContext(ctx context.Context, h ports.Hello, tr ports.T | |||
| 1378 | 1401 | if h.RemoteTarget != nil && h.ResumeToken == 0 { | |
| 1379 | 1402 | return d.routeRemoteTargetWithContext(ctx, h, tr) | |
| 1380 | 1403 | } | |
| 1404 | + | if h.ExactTarget != nil && h.ResumeToken == 0 { | |
| 1405 | + | if err := d.validateExactSessionTarget(ctx, *h.ExactTarget); err != nil { | |
| 1406 | + | return nil, nil, err | |
| 1407 | + | } | |
| 1408 | + | } | |
| 1381 | 1409 | ||
| 1382 | 1410 | // A non-zero token is an authoritative resume credential. If it is unknown, | |
| 1383 | 1411 | // expired, or raced with lifecycle teardown, fail closed instead of routing | |
| @@ -1352,6 +1352,29 @@ func (d *Daemon) waitForTargetRestore(ctx context.Context, name string) error { | |||||
| 1352 | // route resolves a Hello to a session and a freshly attached client, creating | 1352 | // route resolves a Hello to a session and a freshly attached client, creating | ||
| 1353 | // the session for ephemeral/new intents. Direct package callers retain the | 1353 | // the session for ephemeral/new intents. Direct package callers retain the | ||
| 1354 | // daemon lifetime context; inbound handshakes use routeWithContext. | 1354 | // daemon lifetime context; inbound handshakes use routeWithContext. | ||
| 1355 | + | func (d *Daemon) validateExactSessionTarget(ctx context.Context, target ports.ExactSessionTarget) error { | |||
| 1356 | + | if err := target.Validate(); err != nil { | |||
| 1357 | + | return &protoErr{ports.ErrNoSuchSession, "invalid exact session target"} | |||
| 1358 | + | } | |||
| 1359 | + | if err := d.waitForTargetRestore(ctx, target.SessionName); err != nil { | |||
| 1360 | + | return err | |||
| 1361 | + | } | |||
| 1362 | + | ||||
| 1363 | + | d.mu.Lock() | |||
| 1364 | + | defer d.mu.Unlock() | |||
| 1365 | + | if sess := d.findByNameLocked(target.SessionName); sess != nil { | |||
| 1366 | + | if sess.incarnation != target.LifecycleID { | |||
| 1367 | + | return &protoErr{ports.ErrNoSuchSession, "session lifecycle has changed: " + target.SessionName} | |||
| 1368 | + | } | |||
| 1369 | + | return nil | |||
| 1370 | + | } | |||
| 1371 | + | stopped, ok := d.stopped[target.SessionName] | |||
| 1372 | + | if !ok || stopped.purging || stopped.incarnation != target.LifecycleID { | |||
| 1373 | + | return &protoErr{ports.ErrNoSuchSession, "no such session lifecycle: " + target.SessionName} | |||
| 1374 | + | } | |||
| 1375 | + | return nil | |||
| 1376 | + | } | |||
| 1377 | + | ||||
| 1355 | func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedClient, error) { | 1378 | func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedClient, error) { | ||
| 1356 | ctx := d.serveCtx | 1379 | ctx := d.serveCtx | ||
| 1357 | if ctx == nil { | 1380 | if ctx == nil { | ||
| @@ -1378,6 +1401,11 @@ func (d *Daemon) routeWithContext(ctx context.Context, h ports.Hello, tr ports.T | |||||
| 1378 | if h.RemoteTarget != nil && h.ResumeToken == 0 { | 1401 | if h.RemoteTarget != nil && h.ResumeToken == 0 { | ||
| 1379 | return d.routeRemoteTargetWithContext(ctx, h, tr) | 1402 | return d.routeRemoteTargetWithContext(ctx, h, tr) | ||
| 1380 | } | 1403 | } | ||
| 1404 | + | if h.ExactTarget != nil && h.ResumeToken == 0 { | |||
| 1405 | + | if err := d.validateExactSessionTarget(ctx, *h.ExactTarget); err != nil { | |||
| 1406 | + | return nil, nil, err | |||
| 1407 | + | } | |||
| 1408 | + | } | |||
| 1381 | 1409 | ||||
| 1382 | // A non-zero token is an authoritative resume credential. If it is unknown, | 1410 | // A non-zero token is an authoritative resume credential. If it is unknown, | ||
| 1383 | // expired, or raced with lifecycle teardown, fail closed instead of routing | 1411 | // expired, or raced with lifecycle teardown, fail closed instead of routing | ||
A internal/usecase/daemon/exact_route_test.go +54 -0
| @@ -0,0 +1,54 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/vev/internal/domain" | |
| 8 | + | "github.com/bnema/vev/internal/ports" | |
| 9 | + | "github.com/stretchr/testify/require" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | func TestRouteExactSessionTargetSelectsLifecycle(t *testing.T) { | |
| 13 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 14 | + | sess := addControlSession(d, "work", "tab-1", "pane-1") | |
| 15 | + | sess.ephemeral = false | |
| 16 | + | sess.incarnation = remoteLifecycleForTest() | |
| 17 | + | tr, _ := newCapturingTransport(t) | |
| 18 | + | target := ports.ExactSessionTarget{LifecycleID: sess.incarnation, SessionName: sess.name} | |
| 19 | + | hello := ports.Hello{ | |
| 20 | + | Version: ports.ProtocolVersion, | |
| 21 | + | Intent: ports.IntentAttach, | |
| 22 | + | Name: target.SessionName, | |
| 23 | + | Size: domain.Size{Cols: 80, Rows: 24}, | |
| 24 | + | ExactTarget: &target, | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | routed, ac, err := d.routeWithContext(context.Background(), hello, tr) | |
| 28 | + | require.NoError(t, err) | |
| 29 | + | require.Same(t, sess, routed) | |
| 30 | + | require.Same(t, sess, ac.currentAttachmentSession()) | |
| 31 | + | d.clientGone(sess, ac, tr, false) | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | func TestRouteExactSessionTargetRejectsLifecycleReplacement(t *testing.T) { | |
| 35 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 36 | + | sess := addControlSession(d, "work", "tab-1", "pane-1") | |
| 37 | + | sess.ephemeral = false | |
| 38 | + | sess.incarnation = remoteLifecycleForTest() | |
| 39 | + | tr, _ := newCapturingTransport(t) | |
| 40 | + | wrong := sess.incarnation | |
| 41 | + | wrong[0]++ | |
| 42 | + | hello := ports.Hello{ | |
| 43 | + | Version: ports.ProtocolVersion, | |
| 44 | + | Intent: ports.IntentAttach, | |
| 45 | + | Name: sess.name, | |
| 46 | + | Size: domain.Size{Cols: 80, Rows: 24}, | |
| 47 | + | ExactTarget: &ports.ExactSessionTarget{LifecycleID: wrong, SessionName: sess.name}, | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | _, _, err := d.routeWithContext(context.Background(), hello, tr) | |
| 51 | + | var protocol *protoErr | |
| 52 | + | require.ErrorAs(t, err, &protocol) | |
| 53 | + | require.Equal(t, ports.ErrNoSuchSession, protocol.code) | |
| 54 | + | } | |
| @@ -0,0 +1,54 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/vev/internal/domain" | |||
| 8 | + | "github.com/bnema/vev/internal/ports" | |||
| 9 | + | "github.com/stretchr/testify/require" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | func TestRouteExactSessionTargetSelectsLifecycle(t *testing.T) { | |||
| 13 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 14 | + | sess := addControlSession(d, "work", "tab-1", "pane-1") | |||
| 15 | + | sess.ephemeral = false | |||
| 16 | + | sess.incarnation = remoteLifecycleForTest() | |||
| 17 | + | tr, _ := newCapturingTransport(t) | |||
| 18 | + | target := ports.ExactSessionTarget{LifecycleID: sess.incarnation, SessionName: sess.name} | |||
| 19 | + | hello := ports.Hello{ | |||
| 20 | + | Version: ports.ProtocolVersion, | |||
| 21 | + | Intent: ports.IntentAttach, | |||
| 22 | + | Name: target.SessionName, | |||
| 23 | + | Size: domain.Size{Cols: 80, Rows: 24}, | |||
| 24 | + | ExactTarget: &target, | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | routed, ac, err := d.routeWithContext(context.Background(), hello, tr) | |||
| 28 | + | require.NoError(t, err) | |||
| 29 | + | require.Same(t, sess, routed) | |||
| 30 | + | require.Same(t, sess, ac.currentAttachmentSession()) | |||
| 31 | + | d.clientGone(sess, ac, tr, false) | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | func TestRouteExactSessionTargetRejectsLifecycleReplacement(t *testing.T) { | |||
| 35 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 36 | + | sess := addControlSession(d, "work", "tab-1", "pane-1") | |||
| 37 | + | sess.ephemeral = false | |||
| 38 | + | sess.incarnation = remoteLifecycleForTest() | |||
| 39 | + | tr, _ := newCapturingTransport(t) | |||
| 40 | + | wrong := sess.incarnation | |||
| 41 | + | wrong[0]++ | |||
| 42 | + | hello := ports.Hello{ | |||
| 43 | + | Version: ports.ProtocolVersion, | |||
| 44 | + | Intent: ports.IntentAttach, | |||
| 45 | + | Name: sess.name, | |||
| 46 | + | Size: domain.Size{Cols: 80, Rows: 24}, | |||
| 47 | + | ExactTarget: &ports.ExactSessionTarget{LifecycleID: wrong, SessionName: sess.name}, | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | _, _, err := d.routeWithContext(context.Background(), hello, tr) | |||
| 51 | + | var protocol *protoErr | |||
| 52 | + | require.ErrorAs(t, err, &protocol) | |||
| 53 | + | require.Equal(t, ports.ErrNoSuchSession, protocol.code) | |||
| 54 | + | } | |||
M internal/usecase/daemon/wire.go +12 -4
| @@ -8,11 +8,19 @@ import ( | |||
| 8 | 8 | ||
| 9 | 9 | func frameWelcome(s *session, resumeToken uint64) ports.Frame { | |
| 10 | 10 | s.mu.Lock() | |
| 11 | + | var identity *ports.CommittedRouteIdentity | |
| 12 | + | if s.incarnation != (domain.SessionLifecycleID{}) { | |
| 13 | + | identity = &ports.CommittedRouteIdentity{ | |
| 14 | + | Target: ports.ExactSessionTarget{LifecycleID: s.incarnation, SessionName: s.name}, | |
| 15 | + | Ephemeral: s.ephemeral, | |
| 16 | + | } | |
| 17 | + | } | |
| 11 | 18 | w := ports.Welcome{ | |
| 12 | - | SessionID: string(s.id), | |
| 13 | - | SessionName: s.name, | |
| 14 | - | Ephemeral: s.ephemeral, | |
| 15 | - | Capabilities: ports.CapabilityResume, | |
| 19 | + | SessionID: string(s.id), | |
| 20 | + | SessionName: s.name, | |
| 21 | + | Ephemeral: s.ephemeral, | |
| 22 | + | Capabilities: ports.CapabilityResume, | |
| 23 | + | CommittedIdentity: identity, | |
| 16 | 24 | } | |
| 17 | 25 | s.mu.Unlock() | |
| 18 | 26 | w.ResumeToken = resumeToken | |
| @@ -8,11 +8,19 @@ import ( | |||||
| 8 | 8 | ||||
| 9 | func frameWelcome(s *session, resumeToken uint64) ports.Frame { | 9 | func frameWelcome(s *session, resumeToken uint64) ports.Frame { | ||
| 10 | s.mu.Lock() | 10 | s.mu.Lock() | ||
| 11 | + | var identity *ports.CommittedRouteIdentity | |||
| 12 | + | if s.incarnation != (domain.SessionLifecycleID{}) { | |||
| 13 | + | identity = &ports.CommittedRouteIdentity{ | |||
| 14 | + | Target: ports.ExactSessionTarget{LifecycleID: s.incarnation, SessionName: s.name}, | |||
| 15 | + | Ephemeral: s.ephemeral, | |||
| 16 | + | } | |||
| 17 | + | } | |||
| 11 | w := ports.Welcome{ | 18 | w := ports.Welcome{ | ||
| 12 | - | SessionID: string(s.id), | 19 | + | SessionID: string(s.id), |
| 13 | - | SessionName: s.name, | 20 | + | SessionName: s.name, |
| 14 | - | Ephemeral: s.ephemeral, | 21 | + | Ephemeral: s.ephemeral, |
| 15 | - | Capabilities: ports.CapabilityResume, | 22 | + | Capabilities: ports.CapabilityResume, |
| 23 | + | CommittedIdentity: identity, | |||
| 16 | } | 24 | } | ||
| 17 | s.mu.Unlock() | 25 | s.mu.Unlock() | ||
| 18 | w.ResumeToken = resumeToken | 26 | w.ResumeToken = resumeToken | ||