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
18 changes: 18 additions & 0 deletions cmds/dutagent/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"testing"

"github.com/BlindspotSoftware/dutctl/pkg/dut"
"github.com/BlindspotSoftware/dutctl/pkg/keyword"
"gopkg.in/yaml.v3"
)

Expand Down Expand Up @@ -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)
}
}
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
30 changes: 17 additions & 13 deletions cmds/dutctl/dutctl.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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 <device> 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 <device> <command> 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)
Expand Down
22 changes: 22 additions & 0 deletions cmds/dutctl/rpc.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{})
Expand Down
10 changes: 7 additions & 3 deletions pkg/dut/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions pkg/dut/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion pkg/dut/testdata/invalid_reserved_command.yaml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
device1:
desc: "Device 1"
cmds:
lock:
help:
desc: "Report status"
uses:
- module: dummy-status
155 changes: 155 additions & 0 deletions pkg/keyword/keyword.go
Original file line number Diff line number Diff line change
@@ -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 <device> lock".
CommandSlot Position = iota
// AfterCommand keywords follow a command, e.g. "dutctl <device> <cmd> 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
}
38 changes: 38 additions & 0 deletions pkg/keyword/keyword_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
Loading