diff --git a/cmds/dutagent/config_test.go b/cmds/dutagent/config_test.go index d428d52..dee007a 100644 --- a/cmds/dutagent/config_test.go +++ b/cmds/dutagent/config_test.go @@ -58,3 +58,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, dut.ErrReservedName) { + t.Errorf("errors.Is: want %v, got %v", dut.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/cmds/dutctl/dutctl.go b/cmds/dutctl/dutctl.go index 3d397d3..2763308 100644 --- a/cmds/dutctl/dutctl.go +++ b/cmds/dutctl/dutctl.go @@ -168,22 +168,26 @@ 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 := deviceKeywordHandlers[name]; ok { + app.exit(handler(app, name)) + } + + app.exit(app.commandsRPC(name)) } device := app.args[0] command := app.args[1] cmdArgs := app.args[2:] - if len(cmdArgs) > 0 && cmdArgs[0] == "help" { - err := app.detailsRPC(device, command, "help") - app.exit(err) + if len(cmdArgs) > 0 { + if handler, ok := commandKeywordHandlers[cmdArgs[0]]; ok { + app.exit(handler(app, device, command)) + } } - err := app.runRPC(device, command, cmdArgs) - app.exit(err) + app.exit(app.runRPC(device, command, cmdArgs)) } // exit terminates the application. If the provided error is not nil, it is printed to diff --git a/cmds/dutctl/keywords.go b/cmds/dutctl/keywords.go new file mode 100644 index 0000000..e59330c --- /dev/null +++ b/cmds/dutctl/keywords.go @@ -0,0 +1,53 @@ +// 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 main + +import ( + "fmt" + + "github.com/BlindspotSoftware/dutctl/pkg/dut" +) + +// commandKeywordHandler dispatches a reserved keyword that appears after +// a pair (e.g. " help"). +type commandKeywordHandler func(app *application, device, command string) error + +// deviceKeywordHandler dispatches a reserved keyword that appears in +// the device position (e.g. "help" in "dutctl help"). +type deviceKeywordHandler func(app *application, name string) error + +//nolint:gochecknoglobals // dispatch table keyed by dut.CommandKeywords. +var commandKeywordHandlers = map[string]commandKeywordHandler{ + "help": func(app *application, device, command string) error { + return app.detailsRPC(device, command, "help") + }, +} + +//nolint:gochecknoglobals // dispatch table keyed by dut.DeviceKeywords. +var deviceKeywordHandlers = map[string]deviceKeywordHandler{ + "help": func(app *application, _ string) error { + fmt.Fprint(app.stderr, usageAbstract, usageSynopsis, usageDescription) + app.printFlagDefaults() + + return nil + }, +} + +// init enforces that every name in the dut keyword registry has a +// matching handler. Missing one is a programmer error caught at +// startup rather than at request time. +func init() { + for _, kw := range dut.CommandKeywords { + if _, ok := commandKeywordHandlers[kw.Name]; !ok { + panic("dutctl: missing handler for command keyword " + kw.Name) + } + } + + for _, kw := range dut.DeviceKeywords { + if _, ok := deviceKeywordHandlers[kw.Name]; !ok { + panic("dutctl: missing handler for device keyword " + kw.Name) + } + } +} diff --git a/pkg/dut/config.go b/pkg/dut/config.go index 3858e12..c9b6031 100644 --- a/pkg/dut/config.go +++ b/pkg/dut/config.go @@ -88,6 +88,10 @@ func (d *Devlist) UnmarshalYAML(node *yaml.Node) error { for idx := 0; idx < len(node.Content); idx += 2 { devName := node.Content[idx].Value + if IsReservedDeviceName(devName) { + return &ConfigError{Device: devName, Err: ErrReservedName} + } + var dev Device // Decode triggers Device.UnmarshalYAML on the value node. @@ -184,13 +188,17 @@ func decodeCmds(node *yaml.Node) (map[string]Command, error) { cmds := make(map[string]Command, len(node.Content)/2) //nolint:mnd // MappingNode stores key/value as alternating pairs // Iterate command entries to capture the command name for errors. - for i := 0; i < len(node.Content); i += 2 { - cmdName := node.Content[i].Value + for idx := 0; idx < len(node.Content); idx += 2 { + cmdName := node.Content[idx].Value + + if IsReservedCommandName(cmdName) { + return nil, &ConfigError{Command: cmdName, Err: ErrReservedName} + } var cmd Command // Decode triggers Command.UnmarshalYAML. - err := node.Content[i+1].Decode(&cmd) + err := node.Content[idx+1].Decode(&cmd) if err != nil { var configErr *ConfigError if errors.As(err, &configErr) { diff --git a/pkg/dut/reserved.go b/pkg/dut/reserved.go new file mode 100644 index 0000000..cee9d1f --- /dev/null +++ b/pkg/dut/reserved.go @@ -0,0 +1,69 @@ +// 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 dut + +import "errors" + +// CommandKeyword is a name reserved for client-side dispatch when it +// appears as the first argument after a command (e.g. " +// help"). A module command must not be configured with such a name; the +// dutagent will refuse the config at startup. +type CommandKeyword struct { + Name string + Description string +} + +// DeviceKeyword is a name reserved for client-side dispatch when it +// appears as the only argument after a device (e.g. " +// description"). A device must not be configured with such a name. +type DeviceKeyword struct { + Name string + Description string +} + +// CommandKeywords lists every name reserved at the command-keyword +// position. This list is the single source of truth used by both +// dutctl dispatch and dutagent config validation. +// +//nolint:gochecknoglobals // single source of truth for reserved names. +var CommandKeywords = []CommandKeyword{ + {Name: "help", Description: "Show usage for the command."}, +} + +// DeviceKeywords lists every name reserved at the device-keyword +// position. +// +//nolint:gochecknoglobals // single source of truth for reserved names. +var DeviceKeywords = []DeviceKeyword{ + {Name: "help", Description: "Show dutctl usage information."}, +} + +// 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 +} diff --git a/pkg/dut/reserved_test.go b/pkg/dut/reserved_test.go new file mode 100644 index 0000000..6d0b7cd --- /dev/null +++ b/pkg/dut/reserved_test.go @@ -0,0 +1,36 @@ +// 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 dut + +import "testing" + +func TestIsReservedCommandName(t *testing.T) { + tests := []struct { + name string + in string + want bool + }{ + {"reserved help", "help", 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") + } +} diff --git a/pkg/module/module.go b/pkg/module/module.go index 948252d..72e8c48 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/dut.CommandKeywords] and +// [github.com/BlindspotSoftware/dutctl/pkg/dut.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