feat(ports): add command wire messages and bump protocol to 18
- commit
- f7c9bbe80955c986eb60a7e0f57986d9a8831e28
- author
- brice · · 2026-07-23 21:31 UTC
- parent
- 2301cb6
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
8 files · +313 -32
M internal/adapters/shellcmd/runner.go +2 -2
| @@ -18,7 +18,7 @@ type Runner struct{} | |||
| 18 | 18 | func New() Runner { return Runner{} } | |
| 19 | 19 | ||
| 20 | 20 | // Run executes spec.Command through sh -c, capturing bounded stdout and stderr. | |
| 21 | - | func (Runner) Run(ctx context.Context, spec ports.CommandSpec) (ports.CommandResult, error) { | |
| 21 | + | func (Runner) Run(ctx context.Context, spec ports.CommandSpec) (ports.ShellCommandResult, error) { | |
| 22 | 22 | cmd := exec.CommandContext(ctx, "sh", "-c", spec.Command) | |
| 23 | 23 | cmd.Env = spec.Env | |
| 24 | 24 | cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} | |
| @@ -36,7 +36,7 @@ func (Runner) Run(ctx context.Context, spec ports.CommandSpec) (ports.CommandRes | |||
| 36 | 36 | cmd.Stderr = &stderr | |
| 37 | 37 | ||
| 38 | 38 | err := cmd.Run() | |
| 39 | - | res := ports.CommandResult{Stdout: stdout.Bytes(), Stderr: stderr.Bytes()} | |
| 39 | + | res := ports.ShellCommandResult{Stdout: stdout.Bytes(), Stderr: stderr.Bytes()} | |
| 40 | 40 | if err != nil { | |
| 41 | 41 | var exitErr *exec.ExitError | |
| 42 | 42 | if errors.As(err, &exitErr) { | |
| @@ -18,7 +18,7 @@ type Runner struct{} | |||||
| 18 | func New() Runner { return Runner{} } | 18 | func New() Runner { return Runner{} } | ||
| 19 | 19 | ||||
| 20 | // Run executes spec.Command through sh -c, capturing bounded stdout and stderr. | 20 | // Run executes spec.Command through sh -c, capturing bounded stdout and stderr. | ||
| 21 | - | func (Runner) Run(ctx context.Context, spec ports.CommandSpec) (ports.CommandResult, error) { | 21 | + | func (Runner) Run(ctx context.Context, spec ports.CommandSpec) (ports.ShellCommandResult, error) { |
| 22 | cmd := exec.CommandContext(ctx, "sh", "-c", spec.Command) | 22 | cmd := exec.CommandContext(ctx, "sh", "-c", spec.Command) | ||
| 23 | cmd.Env = spec.Env | 23 | cmd.Env = spec.Env | ||
| 24 | cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} | 24 | cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} | ||
| @@ -36,7 +36,7 @@ func (Runner) Run(ctx context.Context, spec ports.CommandSpec) (ports.CommandRes | |||||
| 36 | cmd.Stderr = &stderr | 36 | cmd.Stderr = &stderr | ||
| 37 | 37 | ||||
| 38 | err := cmd.Run() | 38 | err := cmd.Run() | ||
| 39 | - | res := ports.CommandResult{Stdout: stdout.Bytes(), Stderr: stderr.Bytes()} | 39 | + | res := ports.ShellCommandResult{Stdout: stdout.Bytes(), Stderr: stderr.Bytes()} |
| 40 | if err != nil { | 40 | if err != nil { | ||
| 41 | var exitErr *exec.ExitError | 41 | var exitErr *exec.ExitError | ||
| 42 | if errors.As(err, &exitErr) { | 42 | if errors.As(err, &exitErr) { | ||
M internal/ports/frame.go +9 -7
| @@ -1,7 +1,7 @@ | |||
| 1 | 1 | package ports | |
| 2 | 2 | ||
| 3 | 3 | // ProtocolVersion is the current vev IPC wire protocol version. | |
| 4 | - | const ProtocolVersion uint16 = 17 | |
| 4 | + | const ProtocolVersion uint16 = 18 | |
| 5 | 5 | ||
| 6 | 6 | // MsgType identifies the kind of payload carried by a Frame. | |
| 7 | 7 | type MsgType uint8 | |
| @@ -21,13 +21,15 @@ const ( | |||
| 21 | 21 | MsgAck MsgType = 9 | |
| 22 | 22 | MsgImagePush MsgType = 10 | |
| 23 | 23 | MsgClientNotice MsgType = 11 | |
| 24 | + | MsgCommand MsgType = 12 | |
| 24 | 25 | ||
| 25 | - | MsgWelcome MsgType = 16 | |
| 26 | - | MsgError MsgType = 17 | |
| 27 | - | MsgOutput MsgType = 18 | |
| 28 | - | MsgDetached MsgType = 19 | |
| 29 | - | MsgPong MsgType = 20 | |
| 30 | - | MsgSessions MsgType = 21 | |
| 26 | + | MsgWelcome MsgType = 16 | |
| 27 | + | MsgError MsgType = 17 | |
| 28 | + | MsgOutput MsgType = 18 | |
| 29 | + | MsgDetached MsgType = 19 | |
| 30 | + | MsgPong MsgType = 20 | |
| 31 | + | MsgSessions MsgType = 21 | |
| 32 | + | MsgCommandResult MsgType = 22 | |
| 31 | 33 | ) | |
| 32 | 34 | ||
| 33 | 35 | // Frame is the unit of exchange over a Transport: a typed, length-delimited | |
| @@ -1,7 +1,7 @@ | |||||
| 1 | package ports | 1 | package ports | ||
| 2 | 2 | ||||
| 3 | // ProtocolVersion is the current vev IPC wire protocol version. | 3 | // ProtocolVersion is the current vev IPC wire protocol version. | ||
| 4 | - | const ProtocolVersion uint16 = 17 | 4 | + | const ProtocolVersion uint16 = 18 |
| 5 | 5 | ||||
| 6 | // MsgType identifies the kind of payload carried by a Frame. | 6 | // MsgType identifies the kind of payload carried by a Frame. | ||
| 7 | type MsgType uint8 | 7 | type MsgType uint8 | ||
| @@ -21,13 +21,15 @@ const ( | |||||
| 21 | MsgAck MsgType = 9 | 21 | MsgAck MsgType = 9 | ||
| 22 | MsgImagePush MsgType = 10 | 22 | MsgImagePush MsgType = 10 | ||
| 23 | MsgClientNotice MsgType = 11 | 23 | MsgClientNotice MsgType = 11 | ||
| 24 | + | MsgCommand MsgType = 12 | |||
| 24 | 25 | ||||
| 25 | - | MsgWelcome MsgType = 16 | 26 | + | MsgWelcome MsgType = 16 |
| 26 | - | MsgError MsgType = 17 | 27 | + | MsgError MsgType = 17 |
| 27 | - | MsgOutput MsgType = 18 | 28 | + | MsgOutput MsgType = 18 |
| 28 | - | MsgDetached MsgType = 19 | 29 | + | MsgDetached MsgType = 19 |
| 29 | - | MsgPong MsgType = 20 | 30 | + | MsgPong MsgType = 20 |
| 30 | - | MsgSessions MsgType = 21 | 31 | + | MsgSessions MsgType = 21 |
| 32 | + | MsgCommandResult MsgType = 22 | |||
| 31 | ) | 33 | ) | ||
| 32 | 34 | ||||
| 33 | // Frame is the unit of exchange over a Transport: a typed, length-delimited | 35 | // Frame is the unit of exchange over a Transport: a typed, length-delimited | ||
M internal/ports/frame_test.go +13 -2
| @@ -3,8 +3,17 @@ package ports | |||
| 3 | 3 | import "testing" | |
| 4 | 4 | ||
| 5 | 5 | func TestProtocolVersion(t *testing.T) { | |
| 6 | - | if ProtocolVersion != 17 { | |
| 7 | - | t.Fatalf("ProtocolVersion = %d, want 17", ProtocolVersion) | |
| 6 | + | if ProtocolVersion != 18 { | |
| 7 | + | t.Fatalf("ProtocolVersion = %d, want 18", ProtocolVersion) | |
| 8 | + | } | |
| 9 | + | } | |
| 10 | + | ||
| 11 | + | func TestControlMsgTypes(t *testing.T) { | |
| 12 | + | if MsgCommand != 12 { | |
| 13 | + | t.Fatalf("MsgCommand = %d, want 12", MsgCommand) | |
| 14 | + | } | |
| 15 | + | if MsgCommandResult != 22 { | |
| 16 | + | t.Fatalf("MsgCommandResult = %d, want 22", MsgCommandResult) | |
| 8 | 17 | } | |
| 9 | 18 | } | |
| 10 | 19 | ||
| @@ -24,12 +33,14 @@ func TestMsgTypeUnique(t *testing.T) { | |||
| 24 | 33 | {"MsgAck", MsgAck}, | |
| 25 | 34 | {"MsgImagePush", MsgImagePush}, | |
| 26 | 35 | {"MsgClientNotice", MsgClientNotice}, | |
| 36 | + | {"MsgCommand", MsgCommand}, | |
| 27 | 37 | {"MsgWelcome", MsgWelcome}, | |
| 28 | 38 | {"MsgError", MsgError}, | |
| 29 | 39 | {"MsgOutput", MsgOutput}, | |
| 30 | 40 | {"MsgDetached", MsgDetached}, | |
| 31 | 41 | {"MsgPong", MsgPong}, | |
| 32 | 42 | {"MsgSessions", MsgSessions}, | |
| 43 | + | {"MsgCommandResult", MsgCommandResult}, | |
| 33 | 44 | } | |
| 34 | 45 | ||
| 35 | 46 | seen := make(map[MsgType]string, len(tests)) | |
| @@ -3,8 +3,17 @@ 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 != 17 { | 6 | + | if ProtocolVersion != 18 { |
| 7 | - | t.Fatalf("ProtocolVersion = %d, want 17", ProtocolVersion) | 7 | + | t.Fatalf("ProtocolVersion = %d, want 18", ProtocolVersion) |
| 8 | + | } | |||
| 9 | + | } | |||
| 10 | + | ||||
| 11 | + | func TestControlMsgTypes(t *testing.T) { | |||
| 12 | + | if MsgCommand != 12 { | |||
| 13 | + | t.Fatalf("MsgCommand = %d, want 12", MsgCommand) | |||
| 14 | + | } | |||
| 15 | + | if MsgCommandResult != 22 { | |||
| 16 | + | t.Fatalf("MsgCommandResult = %d, want 22", MsgCommandResult) | |||
| 8 | } | 17 | } | ||
| 9 | } | 18 | } | ||
| 10 | 19 | ||||
| @@ -24,12 +33,14 @@ func TestMsgTypeUnique(t *testing.T) { | |||||
| 24 | {"MsgAck", MsgAck}, | 33 | {"MsgAck", MsgAck}, | ||
| 25 | {"MsgImagePush", MsgImagePush}, | 34 | {"MsgImagePush", MsgImagePush}, | ||
| 26 | {"MsgClientNotice", MsgClientNotice}, | 35 | {"MsgClientNotice", MsgClientNotice}, | ||
| 36 | + | {"MsgCommand", MsgCommand}, | |||
| 27 | {"MsgWelcome", MsgWelcome}, | 37 | {"MsgWelcome", MsgWelcome}, | ||
| 28 | {"MsgError", MsgError}, | 38 | {"MsgError", MsgError}, | ||
| 29 | {"MsgOutput", MsgOutput}, | 39 | {"MsgOutput", MsgOutput}, | ||
| 30 | {"MsgDetached", MsgDetached}, | 40 | {"MsgDetached", MsgDetached}, | ||
| 31 | {"MsgPong", MsgPong}, | 41 | {"MsgPong", MsgPong}, | ||
| 32 | {"MsgSessions", MsgSessions}, | 42 | {"MsgSessions", MsgSessions}, | ||
| 43 | + | {"MsgCommandResult", MsgCommandResult}, | |||
| 33 | } | 44 | } | ||
| 34 | 45 | ||||
| 35 | seen := make(map[MsgType]string, len(tests)) | 46 | seen := make(map[MsgType]string, len(tests)) | ||
M internal/ports/mocks/shell_mock.go +8 -8
| @@ -39,22 +39,22 @@ func (_m *MockShellCommandRunner) EXPECT() *MockShellCommandRunner_Expecter { | |||
| 39 | 39 | } | |
| 40 | 40 | ||
| 41 | 41 | // Run provides a mock function for the type MockShellCommandRunner | |
| 42 | - | func (_mock *MockShellCommandRunner) Run(ctx context.Context, spec ports.CommandSpec) (ports.CommandResult, error) { | |
| 42 | + | func (_mock *MockShellCommandRunner) Run(ctx context.Context, spec ports.CommandSpec) (ports.ShellCommandResult, error) { | |
| 43 | 43 | ret := _mock.Called(ctx, spec) | |
| 44 | 44 | ||
| 45 | 45 | if len(ret) == 0 { | |
| 46 | 46 | panic("no return value specified for Run") | |
| 47 | 47 | } | |
| 48 | 48 | ||
| 49 | - | var r0 ports.CommandResult | |
| 49 | + | var r0 ports.ShellCommandResult | |
| 50 | 50 | var r1 error | |
| 51 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, ports.CommandSpec) (ports.CommandResult, error)); ok { | |
| 51 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, ports.CommandSpec) (ports.ShellCommandResult, error)); ok { | |
| 52 | 52 | return returnFunc(ctx, spec) | |
| 53 | 53 | } | |
| 54 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, ports.CommandSpec) ports.CommandResult); ok { | |
| 54 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, ports.CommandSpec) ports.ShellCommandResult); ok { | |
| 55 | 55 | r0 = returnFunc(ctx, spec) | |
| 56 | 56 | } else { | |
| 57 | - | r0 = ret.Get(0).(ports.CommandResult) | |
| 57 | + | r0 = ret.Get(0).(ports.ShellCommandResult) | |
| 58 | 58 | } | |
| 59 | 59 | if returnFunc, ok := ret.Get(1).(func(context.Context, ports.CommandSpec) error); ok { | |
| 60 | 60 | r1 = returnFunc(ctx, spec) | |
| @@ -94,12 +94,12 @@ func (_c *MockShellCommandRunner_Run_Call) Run(run func(ctx context.Context, spe | |||
| 94 | 94 | return _c | |
| 95 | 95 | } | |
| 96 | 96 | ||
| 97 | - | func (_c *MockShellCommandRunner_Run_Call) Return(commandResult ports.CommandResult, err error) *MockShellCommandRunner_Run_Call { | |
| 98 | - | _c.Call.Return(commandResult, err) | |
| 97 | + | func (_c *MockShellCommandRunner_Run_Call) Return(shellCommandResult ports.ShellCommandResult, err error) *MockShellCommandRunner_Run_Call { | |
| 98 | + | _c.Call.Return(shellCommandResult, err) | |
| 99 | 99 | return _c | |
| 100 | 100 | } | |
| 101 | 101 | ||
| 102 | - | func (_c *MockShellCommandRunner_Run_Call) RunAndReturn(run func(ctx context.Context, spec ports.CommandSpec) (ports.CommandResult, error)) *MockShellCommandRunner_Run_Call { | |
| 102 | + | func (_c *MockShellCommandRunner_Run_Call) RunAndReturn(run func(ctx context.Context, spec ports.CommandSpec) (ports.ShellCommandResult, error)) *MockShellCommandRunner_Run_Call { | |
| 103 | 103 | _c.Call.Return(run) | |
| 104 | 104 | return _c | |
| 105 | 105 | } | |
| @@ -39,22 +39,22 @@ func (_m *MockShellCommandRunner) EXPECT() *MockShellCommandRunner_Expecter { | |||||
| 39 | } | 39 | } | ||
| 40 | 40 | ||||
| 41 | // Run provides a mock function for the type MockShellCommandRunner | 41 | // Run provides a mock function for the type MockShellCommandRunner | ||
| 42 | - | func (_mock *MockShellCommandRunner) Run(ctx context.Context, spec ports.CommandSpec) (ports.CommandResult, error) { | 42 | + | func (_mock *MockShellCommandRunner) Run(ctx context.Context, spec ports.CommandSpec) (ports.ShellCommandResult, error) { |
| 43 | ret := _mock.Called(ctx, spec) | 43 | ret := _mock.Called(ctx, spec) | ||
| 44 | 44 | ||||
| 45 | if len(ret) == 0 { | 45 | if len(ret) == 0 { | ||
| 46 | panic("no return value specified for Run") | 46 | panic("no return value specified for Run") | ||
| 47 | } | 47 | } | ||
| 48 | 48 | ||||
| 49 | - | var r0 ports.CommandResult | 49 | + | var r0 ports.ShellCommandResult |
| 50 | var r1 error | 50 | var r1 error | ||
| 51 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, ports.CommandSpec) (ports.CommandResult, error)); ok { | 51 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, ports.CommandSpec) (ports.ShellCommandResult, error)); ok { |
| 52 | return returnFunc(ctx, spec) | 52 | return returnFunc(ctx, spec) | ||
| 53 | } | 53 | } | ||
| 54 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, ports.CommandSpec) ports.CommandResult); ok { | 54 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, ports.CommandSpec) ports.ShellCommandResult); ok { |
| 55 | r0 = returnFunc(ctx, spec) | 55 | r0 = returnFunc(ctx, spec) | ||
| 56 | } else { | 56 | } else { | ||
| 57 | - | r0 = ret.Get(0).(ports.CommandResult) | 57 | + | r0 = ret.Get(0).(ports.ShellCommandResult) |
| 58 | } | 58 | } | ||
| 59 | if returnFunc, ok := ret.Get(1).(func(context.Context, ports.CommandSpec) error); ok { | 59 | if returnFunc, ok := ret.Get(1).(func(context.Context, ports.CommandSpec) error); ok { | ||
| 60 | r1 = returnFunc(ctx, spec) | 60 | r1 = returnFunc(ctx, spec) | ||
| @@ -94,12 +94,12 @@ func (_c *MockShellCommandRunner_Run_Call) Run(run func(ctx context.Context, spe | |||||
| 94 | return _c | 94 | return _c | ||
| 95 | } | 95 | } | ||
| 96 | 96 | ||||
| 97 | - | func (_c *MockShellCommandRunner_Run_Call) Return(commandResult ports.CommandResult, err error) *MockShellCommandRunner_Run_Call { | 97 | + | func (_c *MockShellCommandRunner_Run_Call) Return(shellCommandResult ports.ShellCommandResult, err error) *MockShellCommandRunner_Run_Call { |
| 98 | - | _c.Call.Return(commandResult, err) | 98 | + | _c.Call.Return(shellCommandResult, err) |
| 99 | return _c | 99 | return _c | ||
| 100 | } | 100 | } | ||
| 101 | 101 | ||||
| 102 | - | func (_c *MockShellCommandRunner_Run_Call) RunAndReturn(run func(ctx context.Context, spec ports.CommandSpec) (ports.CommandResult, error)) *MockShellCommandRunner_Run_Call { | 102 | + | func (_c *MockShellCommandRunner_Run_Call) RunAndReturn(run func(ctx context.Context, spec ports.CommandSpec) (ports.ShellCommandResult, error)) *MockShellCommandRunner_Run_Call { |
| 103 | _c.Call.Return(run) | 103 | _c.Call.Return(run) | ||
| 104 | return _c | 104 | return _c | ||
| 105 | } | 105 | } | ||
M internal/ports/shell.go +6 -6
| @@ -14,11 +14,11 @@ type CommandSpec struct { | |||
| 14 | 14 | StderrLimit int | |
| 15 | 15 | } | |
| 16 | 16 | ||
| 17 | - | // CommandResult carries bounded output and the process exit status. ExitCode is | |
| 18 | - | // 0 when the command ran to completion successfully, the process exit code when | |
| 19 | - | // it ran and failed, and -1 when the command could not be started or was | |
| 20 | - | // terminated by a signal. | |
| 21 | - | type CommandResult struct { | |
| 17 | + | // ShellCommandResult carries bounded output and the process exit status. | |
| 18 | + | // ExitCode is 0 when the command ran to completion successfully, the process | |
| 19 | + | // exit code when it ran and failed, and -1 when the command could not be | |
| 20 | + | // started or was terminated by a signal. | |
| 21 | + | type ShellCommandResult struct { | |
| 22 | 22 | Stdout []byte | |
| 23 | 23 | Stderr []byte | |
| 24 | 24 | ExitCode int | |
| @@ -26,5 +26,5 @@ type CommandResult struct { | |||
| 26 | 26 | ||
| 27 | 27 | // ShellCommandRunner runs shell commands and returns bounded output. | |
| 28 | 28 | type ShellCommandRunner interface { | |
| 29 | - | Run(ctx context.Context, spec CommandSpec) (CommandResult, error) | |
| 29 | + | Run(ctx context.Context, spec CommandSpec) (ShellCommandResult, error) | |
| 30 | 30 | } | |
| @@ -14,11 +14,11 @@ type CommandSpec struct { | |||||
| 14 | StderrLimit int | 14 | StderrLimit int | ||
| 15 | } | 15 | } | ||
| 16 | 16 | ||||
| 17 | - | // CommandResult carries bounded output and the process exit status. ExitCode is | 17 | + | // ShellCommandResult carries bounded output and the process exit status. |
| 18 | - | // 0 when the command ran to completion successfully, the process exit code when | 18 | + | // ExitCode is 0 when the command ran to completion successfully, the process |
| 19 | - | // it ran and failed, and -1 when the command could not be started or was | 19 | + | // exit code when it ran and failed, and -1 when the command could not be |
| 20 | - | // terminated by a signal. | 20 | + | // started or was terminated by a signal. |
| 21 | - | type CommandResult struct { | 21 | + | type ShellCommandResult struct { |
| 22 | Stdout []byte | 22 | Stdout []byte | ||
| 23 | Stderr []byte | 23 | Stderr []byte | ||
| 24 | ExitCode int | 24 | ExitCode int | ||
| @@ -26,5 +26,5 @@ type CommandResult struct { | |||||
| 26 | 26 | ||||
| 27 | // ShellCommandRunner runs shell commands and returns bounded output. | 27 | // ShellCommandRunner runs shell commands and returns bounded output. | ||
| 28 | type ShellCommandRunner interface { | 28 | type ShellCommandRunner interface { | ||
| 29 | - | Run(ctx context.Context, spec CommandSpec) (CommandResult, error) | 29 | + | Run(ctx context.Context, spec CommandSpec) (ShellCommandResult, error) |
| 30 | } | 30 | } | ||
M internal/ports/wire.go +145 -0
| @@ -45,6 +45,11 @@ const ( | |||
| 45 | 45 | ErrNameTaken uint16 = 3 | |
| 46 | 46 | ErrServerShutdown uint16 = 4 | |
| 47 | 47 | ErrInvalidSessionName uint16 = 5 | |
| 48 | + | ErrUnknownCommand uint16 = 6 | |
| 49 | + | ErrNotScriptable uint16 = 7 | |
| 50 | + | ErrInvalidCommandArgs uint16 = 8 | |
| 51 | + | ErrNoSuchTarget uint16 = 9 | |
| 52 | + | ErrAmbiguousTarget uint16 = 10 | |
| 48 | 53 | ErrInternal uint16 = 255 | |
| 49 | 54 | ) | |
| 50 | 55 | ||
| @@ -185,6 +190,26 @@ type Kill struct { | |||
| 185 | 190 | All bool | |
| 186 | 191 | } | |
| 187 | 192 | ||
| 193 | + | // CommandRequest asks the daemon to run one control command. Version must | |
| 194 | + | // stay first so a future payload layout can still be rejected cleanly. | |
| 195 | + | type CommandRequest struct { | |
| 196 | + | Version uint16 | |
| 197 | + | Slug string | |
| 198 | + | Args []string | |
| 199 | + | TargetSession string | |
| 200 | + | TargetTab string | |
| 201 | + | TargetPane string | |
| 202 | + | JSON bool | |
| 203 | + | } | |
| 204 | + | ||
| 205 | + | // CommandResult reports a control command's outcome. | |
| 206 | + | type CommandResult struct { | |
| 207 | + | OK bool | |
| 208 | + | Code uint16 | |
| 209 | + | Text string | |
| 210 | + | Output string | |
| 211 | + | } | |
| 212 | + | ||
| 188 | 213 | // SessionInfo describes one session in a Sessions listing. | |
| 189 | 214 | type SessionInfo struct { | |
| 190 | 215 | SessionID string | |
| @@ -342,6 +367,15 @@ func PeekHelloVersion(b []byte) (uint16, bool) { | |||
| 342 | 367 | return binary.BigEndian.Uint16(b), true | |
| 343 | 368 | } | |
| 344 | 369 | ||
| 370 | + | // PeekCommandVersion returns the leading protocol version from a | |
| 371 | + | // CommandRequest payload. | |
| 372 | + | func PeekCommandVersion(b []byte) (uint16, bool) { | |
| 373 | + | if len(b) < 2 { | |
| 374 | + | return 0, false | |
| 375 | + | } | |
| 376 | + | return binary.BigEndian.Uint16(b), true | |
| 377 | + | } | |
| 378 | + | ||
| 345 | 379 | // MarshalHello encodes h into a Hello message payload. | |
| 346 | 380 | func MarshalHello(h Hello) []byte { | |
| 347 | 381 | w := payloadWriter{} | |
| @@ -851,6 +885,117 @@ func UnmarshalKill(b []byte) (Kill, error) { | |||
| 851 | 885 | return Kill{Name: name, All: all != 0}, nil | |
| 852 | 886 | } | |
| 853 | 887 | ||
| 888 | + | // MarshalCommandRequest encodes m into a CommandRequest payload. | |
| 889 | + | func MarshalCommandRequest(m CommandRequest) []byte { | |
| 890 | + | w := payloadWriter{} | |
| 891 | + | w.putUint16(m.Version) | |
| 892 | + | w.putString(m.Slug) | |
| 893 | + | w.putUint16(uint16(len(m.Args))) | |
| 894 | + | for _, arg := range m.Args { | |
| 895 | + | w.putLongString(arg) | |
| 896 | + | } | |
| 897 | + | w.putString(m.TargetSession) | |
| 898 | + | w.putString(m.TargetTab) | |
| 899 | + | w.putString(m.TargetPane) | |
| 900 | + | if m.JSON { | |
| 901 | + | w.putUint8(1) | |
| 902 | + | } else { | |
| 903 | + | w.putUint8(0) | |
| 904 | + | } | |
| 905 | + | return w.b | |
| 906 | + | } | |
| 907 | + | ||
| 908 | + | // UnmarshalCommandRequest decodes a strict CommandRequest payload. | |
| 909 | + | func UnmarshalCommandRequest(b []byte) (CommandRequest, error) { | |
| 910 | + | r := payloadReader{b: b} | |
| 911 | + | var m CommandRequest | |
| 912 | + | var err error | |
| 913 | + | ||
| 914 | + | if m.Version, err = r.getUint16(); err != nil { | |
| 915 | + | return CommandRequest{}, err | |
| 916 | + | } | |
| 917 | + | if m.Slug, err = r.getString(); err != nil { | |
| 918 | + | return CommandRequest{}, err | |
| 919 | + | } | |
| 920 | + | argCount, err := r.getUint16() | |
| 921 | + | if err != nil { | |
| 922 | + | return CommandRequest{}, err | |
| 923 | + | } | |
| 924 | + | // Every argument requires at least a uint32 length prefix. Reject an | |
| 925 | + | // impossible count before allocating from untrusted input. | |
| 926 | + | if uint64(argCount) > uint64(len(r.b)/4) { | |
| 927 | + | return CommandRequest{}, errShortPayload | |
| 928 | + | } | |
| 929 | + | if argCount != 0 { | |
| 930 | + | m.Args = make([]string, 0, int(argCount)) | |
| 931 | + | for range int(argCount) { | |
| 932 | + | arg, err := r.getLongString() | |
| 933 | + | if err != nil { | |
| 934 | + | return CommandRequest{}, err | |
| 935 | + | } | |
| 936 | + | m.Args = append(m.Args, arg) | |
| 937 | + | } | |
| 938 | + | } | |
| 939 | + | if m.TargetSession, err = r.getString(); err != nil { | |
| 940 | + | return CommandRequest{}, err | |
| 941 | + | } | |
| 942 | + | if m.TargetTab, err = r.getString(); err != nil { | |
| 943 | + | return CommandRequest{}, err | |
| 944 | + | } | |
| 945 | + | if m.TargetPane, err = r.getString(); err != nil { | |
| 946 | + | return CommandRequest{}, err | |
| 947 | + | } | |
| 948 | + | jsonFlag, err := r.getUint8() | |
| 949 | + | if err != nil { | |
| 950 | + | return CommandRequest{}, err | |
| 951 | + | } | |
| 952 | + | m.JSON = jsonFlag != 0 | |
| 953 | + | if err := r.done(); err != nil { | |
| 954 | + | return CommandRequest{}, err | |
| 955 | + | } | |
| 956 | + | return m, nil | |
| 957 | + | } | |
| 958 | + | ||
| 959 | + | // MarshalCommandResult encodes m into a CommandResult payload. | |
| 960 | + | func MarshalCommandResult(m CommandResult) []byte { | |
| 961 | + | w := payloadWriter{} | |
| 962 | + | if m.OK { | |
| 963 | + | w.putUint8(1) | |
| 964 | + | } else { | |
| 965 | + | w.putUint8(0) | |
| 966 | + | } | |
| 967 | + | w.putUint16(m.Code) | |
| 968 | + | w.putString(m.Text) | |
| 969 | + | w.putLongString(m.Output) | |
| 970 | + | return w.b | |
| 971 | + | } | |
| 972 | + | ||
| 973 | + | // UnmarshalCommandResult decodes a strict CommandResult payload. | |
| 974 | + | func UnmarshalCommandResult(b []byte) (CommandResult, error) { | |
| 975 | + | r := payloadReader{b: b} | |
| 976 | + | var m CommandResult | |
| 977 | + | var err error | |
| 978 | + | ||
| 979 | + | okFlag, err := r.getUint8() | |
| 980 | + | if err != nil { | |
| 981 | + | return CommandResult{}, err | |
| 982 | + | } | |
| 983 | + | m.OK = okFlag != 0 | |
| 984 | + | if m.Code, err = r.getUint16(); err != nil { | |
| 985 | + | return CommandResult{}, err | |
| 986 | + | } | |
| 987 | + | if m.Text, err = r.getString(); err != nil { | |
| 988 | + | return CommandResult{}, err | |
| 989 | + | } | |
| 990 | + | if m.Output, err = r.getLongString(); err != nil { | |
| 991 | + | return CommandResult{}, err | |
| 992 | + | } | |
| 993 | + | if err := r.done(); err != nil { | |
| 994 | + | return CommandResult{}, err | |
| 995 | + | } | |
| 996 | + | return m, nil | |
| 997 | + | } | |
| 998 | + | ||
| 854 | 999 | // MarshalSessions encodes m into a Sessions message payload: a uint16 count | |
| 855 | 1000 | // followed by that many session records. | |
| 856 | 1001 | func MarshalSessions(m Sessions) []byte { | |
| @@ -45,6 +45,11 @@ const ( | |||||
| 45 | ErrNameTaken uint16 = 3 | 45 | ErrNameTaken uint16 = 3 | ||
| 46 | ErrServerShutdown uint16 = 4 | 46 | ErrServerShutdown uint16 = 4 | ||
| 47 | ErrInvalidSessionName uint16 = 5 | 47 | ErrInvalidSessionName uint16 = 5 | ||
| 48 | + | ErrUnknownCommand uint16 = 6 | |||
| 49 | + | ErrNotScriptable uint16 = 7 | |||
| 50 | + | ErrInvalidCommandArgs uint16 = 8 | |||
| 51 | + | ErrNoSuchTarget uint16 = 9 | |||
| 52 | + | ErrAmbiguousTarget uint16 = 10 | |||
| 48 | ErrInternal uint16 = 255 | 53 | ErrInternal uint16 = 255 | ||
| 49 | ) | 54 | ) | ||
| 50 | 55 | ||||
| @@ -185,6 +190,26 @@ type Kill struct { | |||||
| 185 | All bool | 190 | All bool | ||
| 186 | } | 191 | } | ||
| 187 | 192 | ||||
| 193 | + | // CommandRequest asks the daemon to run one control command. Version must | |||
| 194 | + | // stay first so a future payload layout can still be rejected cleanly. | |||
| 195 | + | type CommandRequest struct { | |||
| 196 | + | Version uint16 | |||
| 197 | + | Slug string | |||
| 198 | + | Args []string | |||
| 199 | + | TargetSession string | |||
| 200 | + | TargetTab string | |||
| 201 | + | TargetPane string | |||
| 202 | + | JSON bool | |||
| 203 | + | } | |||
| 204 | + | ||||
| 205 | + | // CommandResult reports a control command's outcome. | |||
| 206 | + | type CommandResult struct { | |||
| 207 | + | OK bool | |||
| 208 | + | Code uint16 | |||
| 209 | + | Text string | |||
| 210 | + | Output string | |||
| 211 | + | } | |||
| 212 | + | ||||
| 188 | // SessionInfo describes one session in a Sessions listing. | 213 | // SessionInfo describes one session in a Sessions listing. | ||
| 189 | type SessionInfo struct { | 214 | type SessionInfo struct { | ||
| 190 | SessionID string | 215 | SessionID string | ||
| @@ -342,6 +367,15 @@ func PeekHelloVersion(b []byte) (uint16, bool) { | |||||
| 342 | return binary.BigEndian.Uint16(b), true | 367 | return binary.BigEndian.Uint16(b), true | ||
| 343 | } | 368 | } | ||
| 344 | 369 | ||||
| 370 | + | // PeekCommandVersion returns the leading protocol version from a | |||
| 371 | + | // CommandRequest payload. | |||
| 372 | + | func PeekCommandVersion(b []byte) (uint16, bool) { | |||
| 373 | + | if len(b) < 2 { | |||
| 374 | + | return 0, false | |||
| 375 | + | } | |||
| 376 | + | return binary.BigEndian.Uint16(b), true | |||
| 377 | + | } | |||
| 378 | + | ||||
| 345 | // MarshalHello encodes h into a Hello message payload. | 379 | // MarshalHello encodes h into a Hello message payload. | ||
| 346 | func MarshalHello(h Hello) []byte { | 380 | func MarshalHello(h Hello) []byte { | ||
| 347 | w := payloadWriter{} | 381 | w := payloadWriter{} | ||
| @@ -851,6 +885,117 @@ func UnmarshalKill(b []byte) (Kill, error) { | |||||
| 851 | return Kill{Name: name, All: all != 0}, nil | 885 | return Kill{Name: name, All: all != 0}, nil | ||
| 852 | } | 886 | } | ||
| 853 | 887 | ||||
| 888 | + | // MarshalCommandRequest encodes m into a CommandRequest payload. | |||
| 889 | + | func MarshalCommandRequest(m CommandRequest) []byte { | |||
| 890 | + | w := payloadWriter{} | |||
| 891 | + | w.putUint16(m.Version) | |||
| 892 | + | w.putString(m.Slug) | |||
| 893 | + | w.putUint16(uint16(len(m.Args))) | |||
| 894 | + | for _, arg := range m.Args { | |||
| 895 | + | w.putLongString(arg) | |||
| 896 | + | } | |||
| 897 | + | w.putString(m.TargetSession) | |||
| 898 | + | w.putString(m.TargetTab) | |||
| 899 | + | w.putString(m.TargetPane) | |||
| 900 | + | if m.JSON { | |||
| 901 | + | w.putUint8(1) | |||
| 902 | + | } else { | |||
| 903 | + | w.putUint8(0) | |||
| 904 | + | } | |||
| 905 | + | return w.b | |||
| 906 | + | } | |||
| 907 | + | ||||
| 908 | + | // UnmarshalCommandRequest decodes a strict CommandRequest payload. | |||
| 909 | + | func UnmarshalCommandRequest(b []byte) (CommandRequest, error) { | |||
| 910 | + | r := payloadReader{b: b} | |||
| 911 | + | var m CommandRequest | |||
| 912 | + | var err error | |||
| 913 | + | ||||
| 914 | + | if m.Version, err = r.getUint16(); err != nil { | |||
| 915 | + | return CommandRequest{}, err | |||
| 916 | + | } | |||
| 917 | + | if m.Slug, err = r.getString(); err != nil { | |||
| 918 | + | return CommandRequest{}, err | |||
| 919 | + | } | |||
| 920 | + | argCount, err := r.getUint16() | |||
| 921 | + | if err != nil { | |||
| 922 | + | return CommandRequest{}, err | |||
| 923 | + | } | |||
| 924 | + | // Every argument requires at least a uint32 length prefix. Reject an | |||
| 925 | + | // impossible count before allocating from untrusted input. | |||
| 926 | + | if uint64(argCount) > uint64(len(r.b)/4) { | |||
| 927 | + | return CommandRequest{}, errShortPayload | |||
| 928 | + | } | |||
| 929 | + | if argCount != 0 { | |||
| 930 | + | m.Args = make([]string, 0, int(argCount)) | |||
| 931 | + | for range int(argCount) { | |||
| 932 | + | arg, err := r.getLongString() | |||
| 933 | + | if err != nil { | |||
| 934 | + | return CommandRequest{}, err | |||
| 935 | + | } | |||
| 936 | + | m.Args = append(m.Args, arg) | |||
| 937 | + | } | |||
| 938 | + | } | |||
| 939 | + | if m.TargetSession, err = r.getString(); err != nil { | |||
| 940 | + | return CommandRequest{}, err | |||
| 941 | + | } | |||
| 942 | + | if m.TargetTab, err = r.getString(); err != nil { | |||
| 943 | + | return CommandRequest{}, err | |||
| 944 | + | } | |||
| 945 | + | if m.TargetPane, err = r.getString(); err != nil { | |||
| 946 | + | return CommandRequest{}, err | |||
| 947 | + | } | |||
| 948 | + | jsonFlag, err := r.getUint8() | |||
| 949 | + | if err != nil { | |||
| 950 | + | return CommandRequest{}, err | |||
| 951 | + | } | |||
| 952 | + | m.JSON = jsonFlag != 0 | |||
| 953 | + | if err := r.done(); err != nil { | |||
| 954 | + | return CommandRequest{}, err | |||
| 955 | + | } | |||
| 956 | + | return m, nil | |||
| 957 | + | } | |||
| 958 | + | ||||
| 959 | + | // MarshalCommandResult encodes m into a CommandResult payload. | |||
| 960 | + | func MarshalCommandResult(m CommandResult) []byte { | |||
| 961 | + | w := payloadWriter{} | |||
| 962 | + | if m.OK { | |||
| 963 | + | w.putUint8(1) | |||
| 964 | + | } else { | |||
| 965 | + | w.putUint8(0) | |||
| 966 | + | } | |||
| 967 | + | w.putUint16(m.Code) | |||
| 968 | + | w.putString(m.Text) | |||
| 969 | + | w.putLongString(m.Output) | |||
| 970 | + | return w.b | |||
| 971 | + | } | |||
| 972 | + | ||||
| 973 | + | // UnmarshalCommandResult decodes a strict CommandResult payload. | |||
| 974 | + | func UnmarshalCommandResult(b []byte) (CommandResult, error) { | |||
| 975 | + | r := payloadReader{b: b} | |||
| 976 | + | var m CommandResult | |||
| 977 | + | var err error | |||
| 978 | + | ||||
| 979 | + | okFlag, err := r.getUint8() | |||
| 980 | + | if err != nil { | |||
| 981 | + | return CommandResult{}, err | |||
| 982 | + | } | |||
| 983 | + | m.OK = okFlag != 0 | |||
| 984 | + | if m.Code, err = r.getUint16(); err != nil { | |||
| 985 | + | return CommandResult{}, err | |||
| 986 | + | } | |||
| 987 | + | if m.Text, err = r.getString(); err != nil { | |||
| 988 | + | return CommandResult{}, err | |||
| 989 | + | } | |||
| 990 | + | if m.Output, err = r.getLongString(); err != nil { | |||
| 991 | + | return CommandResult{}, err | |||
| 992 | + | } | |||
| 993 | + | if err := r.done(); err != nil { | |||
| 994 | + | return CommandResult{}, err | |||
| 995 | + | } | |||
| 996 | + | return m, nil | |||
| 997 | + | } | |||
| 998 | + | ||||
| 854 | // MarshalSessions encodes m into a Sessions message payload: a uint16 count | 999 | // MarshalSessions encodes m into a Sessions message payload: a uint16 count | ||
| 855 | // followed by that many session records. | 1000 | // followed by that many session records. | ||
| 856 | func MarshalSessions(m Sessions) []byte { | 1001 | func MarshalSessions(m Sessions) []byte { | ||
M internal/ports/wire_test.go +125 -2
| @@ -121,7 +121,7 @@ func TestHelloEnvironmentCodec(t *testing.T) { | |||
| 121 | 121 | Env: []string{"A=B", "XY=123"}, | |
| 122 | 122 | }) | |
| 123 | 123 | want := []byte{ | |
| 124 | - | 0x00, 0x11, 0x00, // version, intent | |
| 124 | + | 0x00, 0x12, 0x00, // version, intent | |
| 125 | 125 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | |
| 126 | 126 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID | |
| 127 | 127 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token | |
| @@ -335,7 +335,7 @@ func TestThemeGenerationClearedWireGoldenPreservesProtocolVersion(t *testing.T) | |||
| 335 | 335 | } | |
| 336 | 336 | want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...) | |
| 337 | 337 | require.Equal(t, want, MarshalTheme(cleared)) | |
| 338 | - | require.Equal(t, uint16(17), ProtocolVersion) | |
| 338 | + | require.Equal(t, uint16(18), ProtocolVersion) | |
| 339 | 339 | } | |
| 340 | 340 | ||
| 341 | 341 | func TestResizeGoldenAndRoundTrip(t *testing.T) { | |
| @@ -442,6 +442,129 @@ func TestWelcomeGoldenAndRoundTrip(t *testing.T) { | |||
| 442 | 442 | assertTrailingGarbageFails(t, full, UnmarshalWelcome) | |
| 443 | 443 | } | |
| 444 | 444 | ||
| 445 | + | func TestCommandErrorCodes(t *testing.T) { | |
| 446 | + | require.Equal(t, uint16(6), ErrUnknownCommand) | |
| 447 | + | require.Equal(t, uint16(7), ErrNotScriptable) | |
| 448 | + | require.Equal(t, uint16(8), ErrInvalidCommandArgs) | |
| 449 | + | require.Equal(t, uint16(9), ErrNoSuchTarget) | |
| 450 | + | require.Equal(t, uint16(10), ErrAmbiguousTarget) | |
| 451 | + | } | |
| 452 | + | ||
| 453 | + | func TestCommandRequestGoldenAndRoundTrip(t *testing.T) { | |
| 454 | + | tests := []struct { | |
| 455 | + | name string | |
| 456 | + | msg CommandRequest | |
| 457 | + | want []byte | |
| 458 | + | }{ | |
| 459 | + | { | |
| 460 | + | name: "minimal", | |
| 461 | + | msg: CommandRequest{Version: ProtocolVersion, Slug: "split-right"}, | |
| 462 | + | want: []byte{ | |
| 463 | + | 0x00, 0x12, | |
| 464 | + | 0x00, 0x0b, 's', 'p', 'l', 'i', 't', '-', 'r', 'i', 'g', 'h', 't', | |
| 465 | + | 0x00, 0x00, | |
| 466 | + | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | |
| 467 | + | 0x00, | |
| 468 | + | }, | |
| 469 | + | }, | |
| 470 | + | { | |
| 471 | + | name: "full", | |
| 472 | + | msg: CommandRequest{ | |
| 473 | + | Version: ProtocolVersion, | |
| 474 | + | Slug: "toast", | |
| 475 | + | Args: []string{"-l", "warn", "tests KO"}, | |
| 476 | + | TargetSession: "dev", | |
| 477 | + | TargetTab: "t_abc", | |
| 478 | + | TargetPane: "p_def", | |
| 479 | + | JSON: true, | |
| 480 | + | }, | |
| 481 | + | want: []byte{ | |
| 482 | + | 0x00, 0x12, | |
| 483 | + | 0x00, 0x05, 't', 'o', 'a', 's', 't', | |
| 484 | + | 0x00, 0x03, | |
| 485 | + | 0x00, 0x00, 0x00, 0x02, '-', 'l', | |
| 486 | + | 0x00, 0x00, 0x00, 0x04, 'w', 'a', 'r', 'n', | |
| 487 | + | 0x00, 0x00, 0x00, 0x08, 't', 'e', 's', 't', 's', ' ', 'K', 'O', | |
| 488 | + | 0x00, 0x03, 'd', 'e', 'v', | |
| 489 | + | 0x00, 0x05, 't', '_', 'a', 'b', 'c', | |
| 490 | + | 0x00, 0x05, 'p', '_', 'd', 'e', 'f', | |
| 491 | + | 0x01, | |
| 492 | + | }, | |
| 493 | + | }, | |
| 494 | + | } | |
| 495 | + | ||
| 496 | + | for _, tt := range tests { | |
| 497 | + | t.Run(tt.name, func(t *testing.T) { | |
| 498 | + | got := MarshalCommandRequest(tt.msg) | |
| 499 | + | require.Equal(t, tt.want, got) | |
| 500 | + | back, err := UnmarshalCommandRequest(got) | |
| 501 | + | require.NoError(t, err) | |
| 502 | + | require.Equal(t, tt.msg, back) | |
| 503 | + | version, ok := PeekCommandVersion(got) | |
| 504 | + | require.True(t, ok) | |
| 505 | + | require.Equal(t, tt.msg.Version, version) | |
| 506 | + | assertAllPrefixesFail(t, got, UnmarshalCommandRequest) | |
| 507 | + | assertTrailingGarbageFails(t, got, UnmarshalCommandRequest) | |
| 508 | + | }) | |
| 509 | + | } | |
| 510 | + | ||
| 511 | + | if _, ok := PeekCommandVersion([]byte{0x00}); ok { | |
| 512 | + | t.Fatal("PeekCommandVersion accepted a one-byte payload") | |
| 513 | + | } | |
| 514 | + | } | |
| 515 | + | ||
| 516 | + | func TestCommandRequestRejectsImpossibleArgumentCount(t *testing.T) { | |
| 517 | + | payload := MarshalCommandRequest(CommandRequest{Version: ProtocolVersion, Slug: "toast"}) | |
| 518 | + | argCountOffset := 2 + 2 + len("toast") | |
| 519 | + | payload[argCountOffset] = 0xff | |
| 520 | + | payload[argCountOffset+1] = 0xff | |
| 521 | + | ||
| 522 | + | if _, err := UnmarshalCommandRequest(payload); err == nil { | |
| 523 | + | t.Fatal("UnmarshalCommandRequest accepted an impossible argument count") | |
| 524 | + | } | |
| 525 | + | } | |
| 526 | + | ||
| 527 | + | func TestCommandResultGoldenAndRoundTrip(t *testing.T) { | |
| 528 | + | tests := []struct { | |
| 529 | + | name string | |
| 530 | + | msg CommandResult | |
| 531 | + | want []byte | |
| 532 | + | }{ | |
| 533 | + | { | |
| 534 | + | name: "error", | |
| 535 | + | msg: CommandResult{Code: ErrNoSuchTarget, Text: "no such session"}, | |
| 536 | + | want: []byte{ | |
| 537 | + | 0x00, | |
| 538 | + | 0x00, 0x09, | |
| 539 | + | 0x00, 0x0f, 'n', 'o', ' ', 's', 'u', 'c', 'h', ' ', 's', 'e', 's', 's', 'i', 'o', 'n', | |
| 540 | + | 0x00, 0x00, 0x00, 0x00, | |
| 541 | + | }, | |
| 542 | + | }, | |
| 543 | + | { | |
| 544 | + | name: "success with output", | |
| 545 | + | msg: CommandResult{OK: true, Text: "listed", Output: "ID\tFOCUSED\n"}, | |
| 546 | + | want: []byte{ | |
| 547 | + | 0x01, | |
| 548 | + | 0x00, 0x00, | |
| 549 | + | 0x00, 0x06, 'l', 'i', 's', 't', 'e', 'd', | |
| 550 | + | 0x00, 0x00, 0x00, 0x0b, 'I', 'D', '\t', 'F', 'O', 'C', 'U', 'S', 'E', 'D', '\n', | |
| 551 | + | }, | |
| 552 | + | }, | |
| 553 | + | } | |
| 554 | + | ||
| 555 | + | for _, tt := range tests { | |
| 556 | + | t.Run(tt.name, func(t *testing.T) { | |
| 557 | + | got := MarshalCommandResult(tt.msg) | |
| 558 | + | require.Equal(t, tt.want, got) | |
| 559 | + | back, err := UnmarshalCommandResult(got) | |
| 560 | + | require.NoError(t, err) | |
| 561 | + | require.Equal(t, tt.msg, back) | |
| 562 | + | assertAllPrefixesFail(t, got, UnmarshalCommandResult) | |
| 563 | + | assertTrailingGarbageFails(t, got, UnmarshalCommandResult) | |
| 564 | + | }) | |
| 565 | + | } | |
| 566 | + | } | |
| 567 | + | ||
| 445 | 568 | func TestErrorMsgGoldenAndRoundTrip(t *testing.T) { | |
| 446 | 569 | msg := ErrorMsg{Code: ErrNameTaken, Text: "name taken"} | |
| 447 | 570 | want := []byte{0x00, 0x03, 0x00, 0x0a, 0x6e, 0x61, 0x6d, 0x65, 0x20, 0x74, 0x61, 0x6b, 0x65, 0x6e} | |
| @@ -121,7 +121,7 @@ func TestHelloEnvironmentCodec(t *testing.T) { | |||||
| 121 | Env: []string{"A=B", "XY=123"}, | 121 | Env: []string{"A=B", "XY=123"}, | ||
| 122 | }) | 122 | }) | ||
| 123 | want := []byte{ | 123 | want := []byte{ | ||
| 124 | - | 0x00, 0x11, 0x00, // version, intent | 124 | + | 0x00, 0x12, 0x00, // version, intent |
| 125 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | 125 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | ||
| 126 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID | 126 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID | ||
| 127 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token | 127 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token | ||
| @@ -335,7 +335,7 @@ func TestThemeGenerationClearedWireGoldenPreservesProtocolVersion(t *testing.T) | |||||
| 335 | } | 335 | } | ||
| 336 | want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...) | 336 | want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...) | ||
| 337 | require.Equal(t, want, MarshalTheme(cleared)) | 337 | require.Equal(t, want, MarshalTheme(cleared)) | ||
| 338 | - | require.Equal(t, uint16(17), ProtocolVersion) | 338 | + | require.Equal(t, uint16(18), ProtocolVersion) |
| 339 | } | 339 | } | ||
| 340 | 340 | ||||
| 341 | func TestResizeGoldenAndRoundTrip(t *testing.T) { | 341 | func TestResizeGoldenAndRoundTrip(t *testing.T) { | ||
| @@ -442,6 +442,129 @@ func TestWelcomeGoldenAndRoundTrip(t *testing.T) { | |||||
| 442 | assertTrailingGarbageFails(t, full, UnmarshalWelcome) | 442 | assertTrailingGarbageFails(t, full, UnmarshalWelcome) | ||
| 443 | } | 443 | } | ||
| 444 | 444 | ||||
| 445 | + | func TestCommandErrorCodes(t *testing.T) { | |||
| 446 | + | require.Equal(t, uint16(6), ErrUnknownCommand) | |||
| 447 | + | require.Equal(t, uint16(7), ErrNotScriptable) | |||
| 448 | + | require.Equal(t, uint16(8), ErrInvalidCommandArgs) | |||
| 449 | + | require.Equal(t, uint16(9), ErrNoSuchTarget) | |||
| 450 | + | require.Equal(t, uint16(10), ErrAmbiguousTarget) | |||
| 451 | + | } | |||
| 452 | + | ||||
| 453 | + | func TestCommandRequestGoldenAndRoundTrip(t *testing.T) { | |||
| 454 | + | tests := []struct { | |||
| 455 | + | name string | |||
| 456 | + | msg CommandRequest | |||
| 457 | + | want []byte | |||
| 458 | + | }{ | |||
| 459 | + | { | |||
| 460 | + | name: "minimal", | |||
| 461 | + | msg: CommandRequest{Version: ProtocolVersion, Slug: "split-right"}, | |||
| 462 | + | want: []byte{ | |||
| 463 | + | 0x00, 0x12, | |||
| 464 | + | 0x00, 0x0b, 's', 'p', 'l', 'i', 't', '-', 'r', 'i', 'g', 'h', 't', | |||
| 465 | + | 0x00, 0x00, | |||
| 466 | + | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | |||
| 467 | + | 0x00, | |||
| 468 | + | }, | |||
| 469 | + | }, | |||
| 470 | + | { | |||
| 471 | + | name: "full", | |||
| 472 | + | msg: CommandRequest{ | |||
| 473 | + | Version: ProtocolVersion, | |||
| 474 | + | Slug: "toast", | |||
| 475 | + | Args: []string{"-l", "warn", "tests KO"}, | |||
| 476 | + | TargetSession: "dev", | |||
| 477 | + | TargetTab: "t_abc", | |||
| 478 | + | TargetPane: "p_def", | |||
| 479 | + | JSON: true, | |||
| 480 | + | }, | |||
| 481 | + | want: []byte{ | |||
| 482 | + | 0x00, 0x12, | |||
| 483 | + | 0x00, 0x05, 't', 'o', 'a', 's', 't', | |||
| 484 | + | 0x00, 0x03, | |||
| 485 | + | 0x00, 0x00, 0x00, 0x02, '-', 'l', | |||
| 486 | + | 0x00, 0x00, 0x00, 0x04, 'w', 'a', 'r', 'n', | |||
| 487 | + | 0x00, 0x00, 0x00, 0x08, 't', 'e', 's', 't', 's', ' ', 'K', 'O', | |||
| 488 | + | 0x00, 0x03, 'd', 'e', 'v', | |||
| 489 | + | 0x00, 0x05, 't', '_', 'a', 'b', 'c', | |||
| 490 | + | 0x00, 0x05, 'p', '_', 'd', 'e', 'f', | |||
| 491 | + | 0x01, | |||
| 492 | + | }, | |||
| 493 | + | }, | |||
| 494 | + | } | |||
| 495 | + | ||||
| 496 | + | for _, tt := range tests { | |||
| 497 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 498 | + | got := MarshalCommandRequest(tt.msg) | |||
| 499 | + | require.Equal(t, tt.want, got) | |||
| 500 | + | back, err := UnmarshalCommandRequest(got) | |||
| 501 | + | require.NoError(t, err) | |||
| 502 | + | require.Equal(t, tt.msg, back) | |||
| 503 | + | version, ok := PeekCommandVersion(got) | |||
| 504 | + | require.True(t, ok) | |||
| 505 | + | require.Equal(t, tt.msg.Version, version) | |||
| 506 | + | assertAllPrefixesFail(t, got, UnmarshalCommandRequest) | |||
| 507 | + | assertTrailingGarbageFails(t, got, UnmarshalCommandRequest) | |||
| 508 | + | }) | |||
| 509 | + | } | |||
| 510 | + | ||||
| 511 | + | if _, ok := PeekCommandVersion([]byte{0x00}); ok { | |||
| 512 | + | t.Fatal("PeekCommandVersion accepted a one-byte payload") | |||
| 513 | + | } | |||
| 514 | + | } | |||
| 515 | + | ||||
| 516 | + | func TestCommandRequestRejectsImpossibleArgumentCount(t *testing.T) { | |||
| 517 | + | payload := MarshalCommandRequest(CommandRequest{Version: ProtocolVersion, Slug: "toast"}) | |||
| 518 | + | argCountOffset := 2 + 2 + len("toast") | |||
| 519 | + | payload[argCountOffset] = 0xff | |||
| 520 | + | payload[argCountOffset+1] = 0xff | |||
| 521 | + | ||||
| 522 | + | if _, err := UnmarshalCommandRequest(payload); err == nil { | |||
| 523 | + | t.Fatal("UnmarshalCommandRequest accepted an impossible argument count") | |||
| 524 | + | } | |||
| 525 | + | } | |||
| 526 | + | ||||
| 527 | + | func TestCommandResultGoldenAndRoundTrip(t *testing.T) { | |||
| 528 | + | tests := []struct { | |||
| 529 | + | name string | |||
| 530 | + | msg CommandResult | |||
| 531 | + | want []byte | |||
| 532 | + | }{ | |||
| 533 | + | { | |||
| 534 | + | name: "error", | |||
| 535 | + | msg: CommandResult{Code: ErrNoSuchTarget, Text: "no such session"}, | |||
| 536 | + | want: []byte{ | |||
| 537 | + | 0x00, | |||
| 538 | + | 0x00, 0x09, | |||
| 539 | + | 0x00, 0x0f, 'n', 'o', ' ', 's', 'u', 'c', 'h', ' ', 's', 'e', 's', 's', 'i', 'o', 'n', | |||
| 540 | + | 0x00, 0x00, 0x00, 0x00, | |||
| 541 | + | }, | |||
| 542 | + | }, | |||
| 543 | + | { | |||
| 544 | + | name: "success with output", | |||
| 545 | + | msg: CommandResult{OK: true, Text: "listed", Output: "ID\tFOCUSED\n"}, | |||
| 546 | + | want: []byte{ | |||
| 547 | + | 0x01, | |||
| 548 | + | 0x00, 0x00, | |||
| 549 | + | 0x00, 0x06, 'l', 'i', 's', 't', 'e', 'd', | |||
| 550 | + | 0x00, 0x00, 0x00, 0x0b, 'I', 'D', '\t', 'F', 'O', 'C', 'U', 'S', 'E', 'D', '\n', | |||
| 551 | + | }, | |||
| 552 | + | }, | |||
| 553 | + | } | |||
| 554 | + | ||||
| 555 | + | for _, tt := range tests { | |||
| 556 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 557 | + | got := MarshalCommandResult(tt.msg) | |||
| 558 | + | require.Equal(t, tt.want, got) | |||
| 559 | + | back, err := UnmarshalCommandResult(got) | |||
| 560 | + | require.NoError(t, err) | |||
| 561 | + | require.Equal(t, tt.msg, back) | |||
| 562 | + | assertAllPrefixesFail(t, got, UnmarshalCommandResult) | |||
| 563 | + | assertTrailingGarbageFails(t, got, UnmarshalCommandResult) | |||
| 564 | + | }) | |||
| 565 | + | } | |||
| 566 | + | } | |||
| 567 | + | ||||
| 445 | func TestErrorMsgGoldenAndRoundTrip(t *testing.T) { | 568 | func TestErrorMsgGoldenAndRoundTrip(t *testing.T) { | ||
| 446 | msg := ErrorMsg{Code: ErrNameTaken, Text: "name taken"} | 569 | msg := ErrorMsg{Code: ErrNameTaken, Text: "name taken"} | ||
| 447 | want := []byte{0x00, 0x03, 0x00, 0x0a, 0x6e, 0x61, 0x6d, 0x65, 0x20, 0x74, 0x61, 0x6b, 0x65, 0x6e} | 570 | want := []byte{0x00, 0x03, 0x00, 0x0a, 0x6e, 0x61, 0x6d, 0x65, 0x20, 0x74, 0x61, 0x6b, 0x65, 0x6e} | ||
M internal/usecase/daemon/bar_script_test.go +5 -5
| @@ -119,7 +119,7 @@ func TestBarScriptRunner(t *testing.T) { | |||
| 119 | 119 | containsEnv(spec.Env, "VEV_PANE_CWD=/repo") && | |
| 120 | 120 | containsEnv(spec.Env, "VEV_COLS=120") && | |
| 121 | 121 | !containsEnv(spec.Env, "VEV_ANCHOR=old") | |
| 122 | - | })).Return(ports.CommandResult{Stdout: []byte("\x1b[31mok\x1b[0m\nignored")}, nil) | |
| 122 | + | })).Return(ports.ShellCommandResult{Stdout: []byte("\x1b[31mok\x1b[0m\nignored")}, nil) | |
| 123 | 123 | runner := barScriptRunner{runner: portRunner, timeout: 50 * time.Millisecond} | |
| 124 | 124 | ||
| 125 | 125 | got, err := runner.run(context.Background(), "printf 'ok\\nignored'", | |
| @@ -137,7 +137,7 @@ func TestBarScriptRunner(t *testing.T) { | |||
| 137 | 137 | wantErr := errors.New("exit 1") | |
| 138 | 138 | portRunner := portsmocks.NewMockShellCommandRunner(t) | |
| 139 | 139 | portRunner.EXPECT().Run(mock.Anything, mock.Anything). | |
| 140 | - | Return(ports.CommandResult{Stdout: []byte("partial\x00 output\nignored"), ExitCode: 1}, wantErr) | |
| 140 | + | Return(ports.ShellCommandResult{Stdout: []byte("partial\x00 output\nignored"), ExitCode: 1}, wantErr) | |
| 141 | 141 | runner := barScriptRunner{runner: portRunner, timeout: time.Second} | |
| 142 | 142 | ||
| 143 | 143 | got, err := runner.run(context.Background(), "false", nil, barScriptContext{}) | |
| @@ -152,9 +152,9 @@ func TestBarScriptRunner(t *testing.T) { | |||
| 152 | 152 | t.Run("timeout propagates deadline", func(t *testing.T) { | |
| 153 | 153 | portRunner := portsmocks.NewMockShellCommandRunner(t) | |
| 154 | 154 | portRunner.EXPECT().Run(mock.Anything, mock.Anything).RunAndReturn( | |
| 155 | - | func(ctx context.Context, _ ports.CommandSpec) (ports.CommandResult, error) { | |
| 155 | + | func(ctx context.Context, _ ports.CommandSpec) (ports.ShellCommandResult, error) { | |
| 156 | 156 | <-ctx.Done() | |
| 157 | - | return ports.CommandResult{ExitCode: -1}, ctx.Err() | |
| 157 | + | return ports.ShellCommandResult{ExitCode: -1}, ctx.Err() | |
| 158 | 158 | }) | |
| 159 | 159 | runner := barScriptRunner{runner: portRunner, timeout: time.Nanosecond} | |
| 160 | 160 | ||
| @@ -169,7 +169,7 @@ func TestBarScriptRunnerBoundsStdoutBeforeSanitize(t *testing.T) { | |||
| 169 | 169 | portRunner := portsmocks.NewMockShellCommandRunner(t) | |
| 170 | 170 | portRunner.EXPECT().Run(mock.Anything, mock.MatchedBy(func(spec ports.CommandSpec) bool { | |
| 171 | 171 | return spec.StdoutLimit == barScriptOutputLimit | |
| 172 | - | })).Return(ports.CommandResult{Stdout: []byte(strings.Repeat("a", 4096))}, nil) | |
| 172 | + | })).Return(ports.ShellCommandResult{Stdout: []byte(strings.Repeat("a", 4096))}, nil) | |
| 173 | 173 | runner := barScriptRunner{runner: portRunner, timeout: time.Second} | |
| 174 | 174 | ||
| 175 | 175 | got, err := runner.run(context.Background(), "long-output", nil, barScriptContext{}) | |
| @@ -119,7 +119,7 @@ func TestBarScriptRunner(t *testing.T) { | |||||
| 119 | containsEnv(spec.Env, "VEV_PANE_CWD=/repo") && | 119 | containsEnv(spec.Env, "VEV_PANE_CWD=/repo") && | ||
| 120 | containsEnv(spec.Env, "VEV_COLS=120") && | 120 | containsEnv(spec.Env, "VEV_COLS=120") && | ||
| 121 | !containsEnv(spec.Env, "VEV_ANCHOR=old") | 121 | !containsEnv(spec.Env, "VEV_ANCHOR=old") | ||
| 122 | - | })).Return(ports.CommandResult{Stdout: []byte("\x1b[31mok\x1b[0m\nignored")}, nil) | 122 | + | })).Return(ports.ShellCommandResult{Stdout: []byte("\x1b[31mok\x1b[0m\nignored")}, nil) |
| 123 | runner := barScriptRunner{runner: portRunner, timeout: 50 * time.Millisecond} | 123 | runner := barScriptRunner{runner: portRunner, timeout: 50 * time.Millisecond} | ||
| 124 | 124 | ||||
| 125 | got, err := runner.run(context.Background(), "printf 'ok\\nignored'", | 125 | got, err := runner.run(context.Background(), "printf 'ok\\nignored'", | ||
| @@ -137,7 +137,7 @@ func TestBarScriptRunner(t *testing.T) { | |||||
| 137 | wantErr := errors.New("exit 1") | 137 | wantErr := errors.New("exit 1") | ||
| 138 | portRunner := portsmocks.NewMockShellCommandRunner(t) | 138 | portRunner := portsmocks.NewMockShellCommandRunner(t) | ||
| 139 | portRunner.EXPECT().Run(mock.Anything, mock.Anything). | 139 | portRunner.EXPECT().Run(mock.Anything, mock.Anything). | ||
| 140 | - | Return(ports.CommandResult{Stdout: []byte("partial\x00 output\nignored"), ExitCode: 1}, wantErr) | 140 | + | Return(ports.ShellCommandResult{Stdout: []byte("partial\x00 output\nignored"), ExitCode: 1}, wantErr) |
| 141 | runner := barScriptRunner{runner: portRunner, timeout: time.Second} | 141 | runner := barScriptRunner{runner: portRunner, timeout: time.Second} | ||
| 142 | 142 | ||||
| 143 | got, err := runner.run(context.Background(), "false", nil, barScriptContext{}) | 143 | got, err := runner.run(context.Background(), "false", nil, barScriptContext{}) | ||
| @@ -152,9 +152,9 @@ func TestBarScriptRunner(t *testing.T) { | |||||
| 152 | t.Run("timeout propagates deadline", func(t *testing.T) { | 152 | t.Run("timeout propagates deadline", func(t *testing.T) { | ||
| 153 | portRunner := portsmocks.NewMockShellCommandRunner(t) | 153 | portRunner := portsmocks.NewMockShellCommandRunner(t) | ||
| 154 | portRunner.EXPECT().Run(mock.Anything, mock.Anything).RunAndReturn( | 154 | portRunner.EXPECT().Run(mock.Anything, mock.Anything).RunAndReturn( | ||
| 155 | - | func(ctx context.Context, _ ports.CommandSpec) (ports.CommandResult, error) { | 155 | + | func(ctx context.Context, _ ports.CommandSpec) (ports.ShellCommandResult, error) { |
| 156 | <-ctx.Done() | 156 | <-ctx.Done() | ||
| 157 | - | return ports.CommandResult{ExitCode: -1}, ctx.Err() | 157 | + | return ports.ShellCommandResult{ExitCode: -1}, ctx.Err() |
| 158 | }) | 158 | }) | ||
| 159 | runner := barScriptRunner{runner: portRunner, timeout: time.Nanosecond} | 159 | runner := barScriptRunner{runner: portRunner, timeout: time.Nanosecond} | ||
| 160 | 160 | ||||
| @@ -169,7 +169,7 @@ func TestBarScriptRunnerBoundsStdoutBeforeSanitize(t *testing.T) { | |||||
| 169 | portRunner := portsmocks.NewMockShellCommandRunner(t) | 169 | portRunner := portsmocks.NewMockShellCommandRunner(t) | ||
| 170 | portRunner.EXPECT().Run(mock.Anything, mock.MatchedBy(func(spec ports.CommandSpec) bool { | 170 | portRunner.EXPECT().Run(mock.Anything, mock.MatchedBy(func(spec ports.CommandSpec) bool { | ||
| 171 | return spec.StdoutLimit == barScriptOutputLimit | 171 | return spec.StdoutLimit == barScriptOutputLimit | ||
| 172 | - | })).Return(ports.CommandResult{Stdout: []byte(strings.Repeat("a", 4096))}, nil) | 172 | + | })).Return(ports.ShellCommandResult{Stdout: []byte(strings.Repeat("a", 4096))}, nil) |
| 173 | runner := barScriptRunner{runner: portRunner, timeout: time.Second} | 173 | runner := barScriptRunner{runner: portRunner, timeout: time.Second} | ||
| 174 | 174 | ||||
| 175 | got, err := runner.run(context.Background(), "long-output", nil, barScriptContext{}) | 175 | got, err := runner.run(context.Background(), "long-output", nil, barScriptContext{}) | ||