From e8b42371535c1e4a575e767d6190760df7f10f57 Mon Sep 17 00:00:00 2001 From: Fabian Wienand Date: Tue, 30 Jun 2026 11:35:57 +0200 Subject: [PATCH 1/4] feat: add reserved keyword registry in pkg/keyword Single source of truth for names reserved by dutctl client-side dispatch, shared by the dutctl client and dutagent config validation. Each keyword carries its description and dispatch handler. Handlers operate through a small Client interface so the package stays free of client-binary internals, letting both binaries import it without a cycle. Signed-off-by: Fabian Wienand --- pkg/keyword/keyword.go | 155 ++++++++++++++++++++++++++++++++++++ pkg/keyword/keyword_test.go | 38 +++++++++ 2 files changed, 193 insertions(+) create mode 100644 pkg/keyword/keyword.go create mode 100644 pkg/keyword/keyword_test.go diff --git a/pkg/keyword/keyword.go b/pkg/keyword/keyword.go new file mode 100644 index 0000000..a9da6d0 --- /dev/null +++ b/pkg/keyword/keyword.go @@ -0,0 +1,155 @@ +// Copyright 2025 Blindspot Software +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// Package keyword is the single source of truth for names reserved by dutctl +// client-side dispatch. It is shared by the dutctl client (which dispatches the +// keywords) and by dutagent config validation via pkg/dut (which rejects device +// or command names that collide with a keyword). +// +// A keyword carries both its reservation metadata (name, description) and the +// handler that dispatches it. Handlers operate through the Client interface so +// this package stays free of client-binary internals. +package keyword + +import "errors" + +// Client is implemented by the dutctl application. Keyword handlers dispatch +// through it instead of referencing the client binary directly, which would +// otherwise be impossible to import. +type Client interface { + Lock(device string, args []string) error + Unlock(device string) error + Details(device, command, kw string) error + PrintUsage() error +} + +// Position identifies where a command keyword appears on the command line. +type Position int + +const ( + // CommandSlot keywords occupy the command position and operate on the + // device, e.g. "dutctl lock". + CommandSlot Position = iota + // AfterCommand keywords follow a command, e.g. "dutctl help". + AfterCommand +) + +// CommandHandlerFunc dispatches a command keyword. device and command are +// args[0] and args[1]; args holds the trailing tokens (args[2:]). +type CommandHandlerFunc func(c Client, device, command string, args []string) error + +// DeviceHandlerFunc dispatches a keyword in the device position, e.g. +// "dutctl help". +type DeviceHandlerFunc func(c Client, name string) error + +// CommandKeyword is a name reserved at the command position. A module command +// must not be configured with such a name; the dutagent refuses the config at +// startup. +type CommandKeyword struct { + Name string + Description string + Position Position + Handler CommandHandlerFunc +} + +// DeviceKeyword is a name reserved at the device position. A device must not be +// configured with such a name. +type DeviceKeyword struct { + Name string + Description string + Handler DeviceHandlerFunc +} + +// CommandKeywords lists every name reserved at the command position. +// +//nolint:gochecknoglobals // single source of truth for reserved command names. +var CommandKeywords = []CommandKeyword{ + { + Name: "help", + Description: "Show usage for the command.", + Position: AfterCommand, + Handler: func(c Client, device, command string, _ []string) error { + return c.Details(device, command, "help") + }, + }, + { + Name: "lock", + Description: "Reserve the device for exclusive use.", + Position: CommandSlot, + Handler: func(c Client, device, _ string, args []string) error { + return c.Lock(device, args) + }, + }, + { + Name: "unlock", + Description: "Release a reservation on the device.", + Position: CommandSlot, + Handler: func(c Client, device, _ string, _ []string) error { + return c.Unlock(device) + }, + }, +} + +// DeviceKeywords lists every name reserved at the device position. +// +//nolint:gochecknoglobals // single source of truth for reserved device names. +var DeviceKeywords = []DeviceKeyword{ + { + Name: "help", + Description: "Show dutctl usage information.", + Handler: func(c Client, _ string) error { + return c.PrintUsage() + }, + }, +} + +// ErrReservedName is returned when a device or command in a dutagent +// configuration is named with a reserved keyword. +var ErrReservedName = errors.New("name is reserved") + +// IsReservedCommandName reports whether name collides with a command keyword. +func IsReservedCommandName(name string) bool { + for _, kw := range CommandKeywords { + if kw.Name == name { + return true + } + } + + return false +} + +// IsReservedDeviceName reports whether name collides with a device keyword. +func IsReservedDeviceName(name string) bool { + for _, kw := range DeviceKeywords { + if kw.Name == name { + return true + } + } + + return false +} + +// CommandHandler returns the handler for the command keyword named name at the +// given position, if one exists. +func CommandHandler(name string, pos Position) (CommandHandlerFunc, bool) { + for _, kw := range CommandKeywords { + if kw.Name == name && kw.Position == pos { + return kw.Handler, true + } + } + + return nil, false +} + +// DeviceHandler returns the handler for the device keyword named name, if one +// exists. +func DeviceHandler(name string) (DeviceHandlerFunc, bool) { + for _, kw := range DeviceKeywords { + if kw.Name == name { + return kw.Handler, true + } + } + + return nil, false +} diff --git a/pkg/keyword/keyword_test.go b/pkg/keyword/keyword_test.go new file mode 100644 index 0000000..fa2c049 --- /dev/null +++ b/pkg/keyword/keyword_test.go @@ -0,0 +1,38 @@ +// Copyright 2025 Blindspot Software +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. +package keyword + +import "testing" + +func TestIsReservedCommandName(t *testing.T) { + tests := []struct { + name string + in string + want bool + }{ + {"reserved help", "help", true}, + {"reserved lock", "lock", true}, + {"reserved unlock", "unlock", true}, + {"plain command", "power", false}, + {"empty", "", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := IsReservedCommandName(tt.in); got != tt.want { + t.Errorf("IsReservedCommandName(%q): want %v, got %v", tt.in, tt.want, got) + } + }) + } +} + +func TestIsReservedDeviceName(t *testing.T) { + if IsReservedDeviceName("foo") { + t.Errorf("plain device name should not be reserved") + } + + if !IsReservedDeviceName("help") { + t.Errorf("help should be reserved as a device keyword") + } +} From 59f2bded11e5a38de800e266ec6e41dfc5e31dc9 Mon Sep 17 00:00:00 2001 From: Fabian Wienand Date: Tue, 30 Jun 2026 11:36:22 +0200 Subject: [PATCH 2/4] feat: reject reserved keyword names in dutagent config Devlist/command decoding now rejects any device or command named with a reserved keyword (keyword.IsReservedDeviceName / IsReservedCommandName), returning keyword.ErrReservedName so a colliding config fails at load. Signed-off-by: Fabian Wienand --- cmds/dutagent/config_test.go | 18 ++++++++++++++++++ .../invalid_config_reserved_command.yaml | 9 +++++++++ .../invalid_config_reserved_device.yaml | 9 +++++++++ pkg/dut/config.go | 10 +++++++--- pkg/dut/config_test.go | 6 ++++-- pkg/dut/testdata/invalid_reserved_command.yaml | 2 +- 6 files changed, 48 insertions(+), 6 deletions(-) create mode 100644 cmds/dutagent/testdata/invalid_config_reserved_command.yaml create mode 100644 cmds/dutagent/testdata/invalid_config_reserved_device.yaml diff --git a/cmds/dutagent/config_test.go b/cmds/dutagent/config_test.go index d428d52..c21ba56 100644 --- a/cmds/dutagent/config_test.go +++ b/cmds/dutagent/config_test.go @@ -10,6 +10,7 @@ import ( "testing" "github.com/BlindspotSoftware/dutctl/pkg/dut" + "github.com/BlindspotSoftware/dutctl/pkg/keyword" "gopkg.in/yaml.v3" ) @@ -58,3 +59,20 @@ func TestInvalidConfigEmptyDevices(t *testing.T) { t.Errorf("errors.Is: want %v, got %v", dut.ErrEmptyDevices, err) } } + +func TestInvalidConfigReservedCommandName(t *testing.T) { + data := loadTestdata(t, "invalid_config_reserved_command.yaml") + + var cfg config + + err := yaml.Unmarshal(data, &cfg) + if err == nil { + t.Fatal("expected error, got nil") + } + + t.Logf("error message: %s", err) + + if !errors.Is(err, keyword.ErrReservedName) { + t.Errorf("errors.Is: want %v, got %v", keyword.ErrReservedName, err) + } +} diff --git a/cmds/dutagent/testdata/invalid_config_reserved_command.yaml b/cmds/dutagent/testdata/invalid_config_reserved_command.yaml new file mode 100644 index 0000000..bf517a2 --- /dev/null +++ b/cmds/dutagent/testdata/invalid_config_reserved_command.yaml @@ -0,0 +1,9 @@ +version: 0 +devices: + device1: + desc: "Device 1" + cmds: + help: + desc: "Reserved command name" + uses: + - module: dummy-status diff --git a/cmds/dutagent/testdata/invalid_config_reserved_device.yaml b/cmds/dutagent/testdata/invalid_config_reserved_device.yaml new file mode 100644 index 0000000..81e4fd9 --- /dev/null +++ b/cmds/dutagent/testdata/invalid_config_reserved_device.yaml @@ -0,0 +1,9 @@ +version: 0 +devices: + help: + desc: "Reserved device name" + cmds: + status: + desc: "Report status" + uses: + - module: dummy-status diff --git a/pkg/dut/config.go b/pkg/dut/config.go index e6fc58f..e6ff538 100644 --- a/pkg/dut/config.go +++ b/pkg/dut/config.go @@ -9,6 +9,7 @@ import ( "fmt" "strings" + "github.com/BlindspotSoftware/dutctl/pkg/keyword" "github.com/BlindspotSoftware/dutctl/pkg/module" "github.com/go-playground/validator/v10" "gopkg.in/yaml.v3" @@ -55,7 +56,6 @@ var ( ErrModuleNotFound = errors.New("module not found") ErrEmptyDevices = errors.New("devices must not be empty") ErrNoCommands = errors.New("device must have at least one command") - ErrReservedCommand = errors.New("command name is reserved") ) // UnmarshalYAML unmarshals a Devlist from a YAML node, wrapping errors @@ -89,6 +89,10 @@ func (d *Devlist) UnmarshalYAML(node *yaml.Node) error { for idx := 0; idx < len(node.Content); idx += 2 { devName := node.Content[idx].Value + if keyword.IsReservedDeviceName(devName) { + return &ConfigError{Device: devName, Err: keyword.ErrReservedName} + } + var dev Device // Decode triggers Device.UnmarshalYAML on the value node. @@ -188,8 +192,8 @@ func decodeCmds(node *yaml.Node) (map[string]Command, error) { for idx := 0; idx < len(node.Content); idx += 2 { cmdName := node.Content[idx].Value - if cmdName == "lock" || cmdName == "unlock" { - return nil, &ConfigError{Command: cmdName, Err: ErrReservedCommand} + if keyword.IsReservedCommandName(cmdName) { + return nil, &ConfigError{Command: cmdName, Err: keyword.ErrReservedName} } var cmd Command diff --git a/pkg/dut/config_test.go b/pkg/dut/config_test.go index 75cd85d..559314e 100644 --- a/pkg/dut/config_test.go +++ b/pkg/dut/config_test.go @@ -13,6 +13,8 @@ import ( "gopkg.in/yaml.v3" + "github.com/BlindspotSoftware/dutctl/pkg/keyword" + // Register dummy modules so module.New() succeeds in tests. _ "github.com/BlindspotSoftware/dutctl/pkg/module/dummy" ) @@ -144,9 +146,9 @@ func TestInvalidConfig(t *testing.T) { { name: "reserved_command_name", file: "invalid_reserved_command.yaml", - wantSentinel: ErrReservedCommand, + wantSentinel: keyword.ErrReservedName, wantDevice: "device1", - wantCommand: "lock", + wantCommand: "help", }, // Null device value diff --git a/pkg/dut/testdata/invalid_reserved_command.yaml b/pkg/dut/testdata/invalid_reserved_command.yaml index 28dec73..9b99342 100644 --- a/pkg/dut/testdata/invalid_reserved_command.yaml +++ b/pkg/dut/testdata/invalid_reserved_command.yaml @@ -1,7 +1,7 @@ device1: desc: "Device 1" cmds: - lock: + help: desc: "Report status" uses: - module: dummy-status From 96975eddedcd27a0d9a3aca0f3fb7975d7126498 Mon Sep 17 00:00:00 2001 From: Fabian Wienand Date: Tue, 30 Jun 2026 11:36:30 +0200 Subject: [PATCH 3/4] feat: dispatch reserved keywords via the registry in dutctl Replace the hardcoded lock/unlock switch and help check with registry lookups (keyword.CommandHandler / DeviceHandler). The application implements keyword.Client, so handlers live in pkg/keyword and stay in sync with config validation without a separate dispatch table. Signed-off-by: Fabian Wienand --- cmds/dutctl/dutctl.go | 30 +++++++++++++++++------------- cmds/dutctl/rpc.go | 22 ++++++++++++++++++++++ 2 files changed, 39 insertions(+), 13 deletions(-) diff --git a/cmds/dutctl/dutctl.go b/cmds/dutctl/dutctl.go index d9dc891..e162e06 100644 --- a/cmds/dutctl/dutctl.go +++ b/cmds/dutctl/dutctl.go @@ -18,6 +18,7 @@ import ( "connectrpc.com/connect" "github.com/BlindspotSoftware/dutctl/internal/buildinfo" "github.com/BlindspotSoftware/dutctl/internal/output" + "github.com/BlindspotSoftware/dutctl/pkg/keyword" "github.com/BlindspotSoftware/dutctl/pkg/lock" "github.com/BlindspotSoftware/dutctl/protobuf/gen/dutctl/v1/dutctlv1connect" ) @@ -199,29 +200,32 @@ func (app *application) start() { } if len(app.args) == 1 { - device := app.args[0] - err := app.commandsRPC(device) - app.exit(err) + name := app.args[0] + + if handler, ok := keyword.DeviceHandler(name); ok { + app.exit(handler(app, name)) + } + + app.exit(app.commandsRPC(name)) } device := app.args[0] command := app.args[1] cmdArgs := app.args[2:] - switch command { - case "lock": - app.exit(app.lockRPC(device, cmdArgs)) - case "unlock": - app.exit(app.unlockRPC(device)) + // Device-scoped keyword in the command slot, e.g. "dutctl lock". + if handler, ok := keyword.CommandHandler(command, keyword.CommandSlot); ok { + app.exit(handler(app, device, command, cmdArgs)) } - if len(cmdArgs) > 0 && cmdArgs[0] == "help" { - err := app.detailsRPC(device, command, "help") - app.exit(err) + // Keyword after a command, e.g. "dutctl help". + if len(cmdArgs) > 0 { + if handler, ok := keyword.CommandHandler(cmdArgs[0], keyword.AfterCommand); ok { + app.exit(handler(app, device, command, cmdArgs)) + } } - err := app.runRPC(device, command, cmdArgs) - app.exit(err) + app.exit(app.runRPC(device, command, cmdArgs)) } // exit terminates the application. Buffered diagnostics (the warning summary) diff --git a/cmds/dutctl/rpc.go b/cmds/dutctl/rpc.go index a3c7893..0e4ff23 100644 --- a/cmds/dutctl/rpc.go +++ b/cmds/dutctl/rpc.go @@ -30,6 +30,28 @@ import ( // "interrupted" status with exit code 130, not as a failure. var errInterrupted = errors.New("interrupted") +// The following methods make *application satisfy keyword.Client, so reserved +// keyword handlers in pkg/keyword can dispatch through it. + +func (app *application) Lock(device string, args []string) error { + return app.lockRPC(device, args) +} + +func (app *application) Unlock(device string) error { + return app.unlockRPC(device) +} + +func (app *application) Details(device, command, kw string) error { + return app.detailsRPC(device, command, kw) +} + +func (app *application) PrintUsage() error { + fmt.Fprint(app.stderr, usageAbstract, usageSynopsis, usageDescription) + app.printFlagDefaults() + + return nil +} + func (app *application) listRPC() error { ctx := context.Background() req := connect.NewRequest(&pb.ListRequest{}) From 8bbc2f8f1a63ea449cd40fa1cf2523c8e3b7cd87 Mon Sep 17 00:00:00 2001 From: Fabian Wienand Date: Tue, 30 Jun 2026 11:36:39 +0200 Subject: [PATCH 4/4] docs: note reserved keyword constraint on the Module interface Point the doc links at pkg/keyword.CommandKeywords / DeviceKeywords. Signed-off-by: Fabian Wienand --- pkg/module/module.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/pkg/module/module.go b/pkg/module/module.go index 948252d..34c5ccd 100644 --- a/pkg/module/module.go +++ b/pkg/module/module.go @@ -24,6 +24,14 @@ var ( // Module is a building block of a command running on a device-under-test (DUT). // Implementations of this interface are the actual steps that are executed on a DUT. +// +// Reserved keywords: certain names are reserved for dutctl client-side dispatch +// (see [github.com/BlindspotSoftware/dutctl/pkg/keyword.CommandKeywords] and +// [github.com/BlindspotSoftware/dutctl/pkg/keyword.DeviceKeywords]). A module command +// must not be configured with a name from those lists; the dutagent refuses such +// configs at startup. Furthermore, modules MUST NOT expect a reserved keyword as +// the first argument to Run: the dutctl client intercepts these and never invokes +// Run with them. type Module interface { // Help provides usage information. // The returned string should contain a description of the module the supported