Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions cmds/dutagent/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
9 changes: 9 additions & 0 deletions cmds/dutagent/testdata/invalid_config_reserved_command.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
version: 0
devices:
device1:
desc: "Device 1"
cmds:
help:
desc: "Reserved command name"
uses:
- module: dummy-status
9 changes: 9 additions & 0 deletions cmds/dutagent/testdata/invalid_config_reserved_device.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
version: 0
devices:
help:
desc: "Reserved device name"
cmds:
status:
desc: "Report status"
uses:
- module: dummy-status
20 changes: 12 additions & 8 deletions cmds/dutctl/dutctl.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
53 changes: 53 additions & 0 deletions cmds/dutctl/keywords.go
Original file line number Diff line number Diff line change
@@ -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 <device> <command> pair (e.g. "<device> <cmd> 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)
}
}
}
14 changes: 11 additions & 3 deletions pkg/dut/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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) {
Expand Down
69 changes: 69 additions & 0 deletions pkg/dut/reserved.go
Original file line number Diff line number Diff line change
@@ -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. "<device> <cmd>
// 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. "<device>
// 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
}
36 changes: 36 additions & 0 deletions pkg/dut/reserved_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
8 changes: 8 additions & 0 deletions pkg/module/module.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down