aboutsummaryrefslogtreecommitdiff
path: root/pkg/config
diff options
context:
space:
mode:
authorJulien Dessaux2021-11-17 10:13:06 +0100
committerJulien Dessaux2021-11-17 10:13:06 +0100
commitc3263c03776401ad1263a9fb8f5a44a8ed44d61b (patch)
tree7dac91753cb4428ede2ba72fb09eca9ba6c2daab /pkg/config
parentUpdated dependencies (diff)
downloadshell-game-launcher-c3263c03776401ad1263a9fb8f5a44a8ed44d61b.tar.gz
shell-game-launcher-c3263c03776401ad1263a9fb8f5a44a8ed44d61b.tar.bz2
shell-game-launcher-c3263c03776401ad1263a9fb8f5a44a8ed44d61b.zip
Refactored package structure
Diffstat (limited to 'pkg/config')
-rw-r--r--pkg/config/action.go54
-rw-r--r--pkg/config/action_test.go105
-rw-r--r--pkg/config/app.go55
-rw-r--r--pkg/config/app_test.go90
-rw-r--r--pkg/config/command.go32
-rw-r--r--pkg/config/command_test.go51
-rw-r--r--pkg/config/config.go64
-rw-r--r--pkg/config/config_test.go221
-rw-r--r--pkg/config/game.go63
-rw-r--r--pkg/config/game_test.go107
-rw-r--r--pkg/config/menu.go113
-rw-r--r--pkg/config/menu_test.go71
-rw-r--r--pkg/config/test_data/duplicate_game.yaml30
-rw-r--r--pkg/config/test_data/duplicate_menu.yaml28
-rw-r--r--pkg/config/test_data/fake_nethack_directory/.keep0
-rw-r--r--pkg/config/test_data/invalid_app.yaml11
-rw-r--r--pkg/config/test_data/invalid_game.yaml21
-rw-r--r--pkg/config/test_data/invalid_menus.yaml19
-rw-r--r--pkg/config/test_data/invalid_yaml1
-rw-r--r--pkg/config/test_data/minimal.yaml18
-rw-r--r--pkg/config/test_data/no_anonymous_menu.yaml18
-rw-r--r--pkg/config/test_data/no_logged_in_menu.yaml18
-rw-r--r--pkg/config/test_data/non_existant_chopts.yaml18
-rw-r--r--pkg/config/test_data/non_existant_game.yaml18
-rw-r--r--pkg/config/test_data/non_existant_menu.yaml18
-rw-r--r--pkg/config/test_data/not_enough_menus.yaml13
-rw-r--r--pkg/config/test_data/unreachable_game.yaml25
-rw-r--r--pkg/config/test_data/unreachable_menu.yaml23
28 files changed, 1305 insertions, 0 deletions
diff --git a/pkg/config/action.go b/pkg/config/action.go
new file mode 100644
index 0000000..c63308e
--- /dev/null
+++ b/pkg/config/action.go
@@ -0,0 +1,54 @@
+package config
+
+import (
+ "strings"
+
+ "github.com/pkg/errors"
+)
+
+func validateAction(action string) error {
+ tokens := strings.Split(action, " ")
+ switch tokens[0] {
+ case "chmail":
+ if len(tokens) != 1 {
+ return errors.New("chmail action takes no arguments")
+ }
+ case "login":
+ if len(tokens) != 1 {
+ return errors.New("login action takes no arguments")
+ }
+ case "menu":
+ if len(tokens) != 2 {
+ return errors.New("menu action takes exactly one argument")
+ }
+ // menu existence is tested in global config
+ case "passwd":
+ if len(tokens) != 1 {
+ return errors.New("passwd action takes no arguments")
+ }
+ case "play":
+ if len(tokens) != 2 {
+ return errors.New("play action takes exactly one argument")
+ }
+ // game existence is tested in global config
+ case "register":
+ if len(tokens) != 1 {
+ return errors.New("register action takes no arguments")
+ }
+ case "replay":
+ if len(tokens) != 1 {
+ return errors.New("replay action takes no arguments")
+ }
+ case "watch":
+ if len(tokens) != 1 {
+ return errors.New("watch action takes no arguments")
+ }
+ case "quit":
+ if len(tokens) != 1 {
+ return errors.New("quit action takes no arguments")
+ }
+ default:
+ return errors.New("Invalid action : " + tokens[0])
+ }
+ return nil
+}
diff --git a/pkg/config/action_test.go b/pkg/config/action_test.go
new file mode 100644
index 0000000..dbc6ae3
--- /dev/null
+++ b/pkg/config/action_test.go
@@ -0,0 +1,105 @@
+package config
+
+import "testing"
+
+func TestActionValidate(t *testing.T) {
+ // Empty action
+ menuEntry := MenuEntry{Key: "l", Label: "label", Action: ""}
+ if err := menuEntry.validate(); err == nil {
+ t.Fatal("An action cannot be empty")
+ }
+ // Invalid action
+ menuEntry = MenuEntry{Key: "l", Label: "label", Action: "invalid"}
+ if err := menuEntry.validate(); err == nil {
+ t.Fatal("An action must be valid")
+ }
+ // chmail
+ menuEntry = MenuEntry{Key: "l", Label: "label", Action: "chmail a"}
+ if err := menuEntry.validate(); err == nil {
+ t.Fatal("chmail action does not take arguments")
+ }
+ menuEntry = MenuEntry{Key: "l", Label: "label", Action: "chmail"}
+ if err := menuEntry.validate(); err != nil {
+ t.Fatalf("chmail action without arguments is valid\nerror: +%v", err)
+ }
+ // login
+ menuEntry = MenuEntry{Key: "l", Label: "label", Action: "login a"}
+ if err := menuEntry.validate(); err == nil {
+ t.Fatal("login action does not take arguments")
+ }
+ menuEntry = MenuEntry{Key: "l", Label: "label", Action: "login"}
+ if err := menuEntry.validate(); err != nil {
+ t.Fatalf("login action without arguments is valid\nerror: +%v", err)
+ }
+ // menu
+ menuEntry = MenuEntry{Key: "l", Label: "label", Action: "menu"}
+ if err := menuEntry.validate(); err == nil {
+ t.Fatal("menu action takes exactly one argument")
+ }
+ menuEntry = MenuEntry{Key: "l", Label: "label", Action: "menu test plop"}
+ if err := menuEntry.validate(); err == nil {
+ t.Fatal("menu action takes exactly one argument")
+ }
+ menuEntry = MenuEntry{Key: "l", Label: "label", Action: "menu test"}
+ if err := menuEntry.validate(); err != nil {
+ t.Fatalf("menu action with one argument is valid\nerror: +%v", err)
+ }
+ // passwd
+ menuEntry = MenuEntry{Key: "l", Label: "label", Action: "passwd a"}
+ if err := menuEntry.validate(); err == nil {
+ t.Fatal("passwd action does not take arguments")
+ }
+ menuEntry = MenuEntry{Key: "l", Label: "label", Action: "passwd"}
+ if err := menuEntry.validate(); err != nil {
+ t.Fatalf("passwd action without arguments is valid\nerror: +%v", err)
+ }
+ // play
+ menuEntry = MenuEntry{Key: "l", Label: "label", Action: "play"}
+ if err := menuEntry.validate(); err == nil {
+ t.Fatal("play action takes exactly one argument")
+ }
+ menuEntry = MenuEntry{Key: "l", Label: "label", Action: "play test plop"}
+ if err := menuEntry.validate(); err == nil {
+ t.Fatal("play action takes exactly one argument")
+ }
+ menuEntry = MenuEntry{Key: "l", Label: "label", Action: "play test"}
+ if err := menuEntry.validate(); err != nil {
+ t.Fatalf("play action with one argument is valid\nerror: +%v", err)
+ }
+ // register
+ menuEntry = MenuEntry{Key: "l", Label: "label", Action: "register a"}
+ if err := menuEntry.validate(); err == nil {
+ t.Fatal("register action does not take arguments")
+ }
+ menuEntry = MenuEntry{Key: "l", Label: "label", Action: "register"}
+ if err := menuEntry.validate(); err != nil {
+ t.Fatalf("register action without arguments is valid\nerror: +%v", err)
+ }
+ // replay
+ menuEntry = MenuEntry{Key: "l", Label: "label", Action: "replay a"}
+ if err := menuEntry.validate(); err == nil {
+ t.Fatal("replay action does not take arguments")
+ }
+ menuEntry = MenuEntry{Key: "l", Label: "label", Action: "replay"}
+ if err := menuEntry.validate(); err != nil {
+ t.Fatalf("replay action without arguments is valid\nerror: +%v", err)
+ }
+ // watch
+ menuEntry = MenuEntry{Key: "l", Label: "label", Action: "watch a"}
+ if err := menuEntry.validate(); err == nil {
+ t.Fatal("watch action does not take arguments")
+ }
+ menuEntry = MenuEntry{Key: "l", Label: "label", Action: "watch"}
+ if err := menuEntry.validate(); err != nil {
+ t.Fatalf("watch action without arguments is valid\nerror: +%v", err)
+ }
+ // quit
+ menuEntry = MenuEntry{Key: "l", Label: "label", Action: "quit a"}
+ if err := menuEntry.validate(); err == nil {
+ t.Fatal("quit action does not take arguments")
+ }
+ menuEntry = MenuEntry{Key: "l", Label: "label", Action: "quit"}
+ if err := menuEntry.validate(); err != nil {
+ t.Fatalf("quit action without arguments is valid\nerror: +%v", err)
+ }
+}
diff --git a/pkg/config/app.go b/pkg/config/app.go
new file mode 100644
index 0000000..2787517
--- /dev/null
+++ b/pkg/config/app.go
@@ -0,0 +1,55 @@
+package config
+
+import (
+ "os"
+
+ "github.com/pkg/errors"
+ "golang.org/x/sys/unix"
+)
+
+// App struct contains the configuration for this application
+type App struct {
+ // WorkingDirectory is the program working directory where the user data, save files and scores are stored
+ WorkingDirectory string `yaml:"WorkingDirectory"`
+ // MaxUsers is the maximum amount of registered users to allow
+ MaxUsers int `yaml:"MaxUsers"`
+ // AllowRegistration allows registration of new users
+ AllowRegistration bool `yaml:"AllowRegistration"`
+ // MaxNickLen Maximum length for a nickname
+ MaxNickLen int `yaml:"MaxNickLen"`
+ // MenuMaxIdleTime is the maximum number of seconds a user can be idle on the menu before the program exits
+ MenuMaxIdleTime int `yaml:"MenuMaxIdleTime"`
+ // PostLoginCommands is the list of commands to execute upon login, like creating save directories for games
+ PostLoginCommands []string `yaml:"PostLoginCommands"`
+ // TODO admin contact
+}
+
+func (a *App) validate() error {
+ // WorkingDirectory
+ if err := os.MkdirAll(a.WorkingDirectory, 0700); err != nil {
+ return errors.Wrapf(err, "Invalid WorkingDirectory : %s", a.WorkingDirectory)
+ }
+ if err := unix.Access(a.WorkingDirectory, unix.W_OK|unix.R_OK|unix.X_OK); err != nil {
+ return errors.Wrapf(err, "invalid WorkingDirectory : %s", a.WorkingDirectory)
+ }
+ // MaxUsers
+ if a.MaxUsers <= 0 {
+ return errors.New("MaxUsers must be a positive integer")
+ }
+ // AllowRegistration is just a bool, nothing to validate
+ // MaxNickLen
+ if a.MaxNickLen <= 0 {
+ return errors.New("MaxNickLen must be a positive integer")
+ }
+ // MenuMaxIdleTime
+ if a.MenuMaxIdleTime <= 0 {
+ return errors.New("MenuMaxIdleTime must be a positive integer")
+ }
+ // PostLoginCommands
+ for i := 0; i < len(a.PostLoginCommands); i++ {
+ if err := validateCommand(a.PostLoginCommands[i]); err != nil {
+ return errors.Wrap(err, "Failed to validate PostLoginCommands")
+ }
+ }
+ return nil
+}
diff --git a/pkg/config/app_test.go b/pkg/config/app_test.go
new file mode 100644
index 0000000..5a6cf41
--- /dev/null
+++ b/pkg/config/app_test.go
@@ -0,0 +1,90 @@
+package config
+
+import (
+ "os"
+ "testing"
+)
+
+func TestAppvalidate(t *testing.T) {
+ // WorkingDirectory
+ t.Cleanup(func() { os.RemoveAll("no_permission/") })
+ if err := os.Mkdir("no_permission/", 0000); err != nil {
+ t.Fatal("Could not create test directory")
+ }
+ app := App{WorkingDirectory: "no_permission/cannot_work"}
+ if err := app.validate(); err == nil {
+ t.Fatal("no_permission/cannot_wor/k should not be a valid working directory")
+ }
+ app = App{WorkingDirectory: "no_permission/"}
+ if err := app.validate(); err == nil {
+ t.Fatal("no_permission/ should not be a valid working directory")
+ }
+
+ // MaxUsers
+ t.Cleanup(func() { os.RemoveAll("var/") })
+ app = App{
+ WorkingDirectory: "var/",
+ MaxUsers: 0,
+ }
+ if err := app.validate(); err == nil {
+ t.Fatal("Negative MaxUsers should not be valid")
+ }
+
+ // AllowRegistration is just a bool, nothing to test
+
+ // MaxNickLen
+ t.Cleanup(func() { os.RemoveAll("var/") })
+ app = App{
+ WorkingDirectory: "var/",
+ MaxUsers: 1,
+ MaxNickLen: 0,
+ }
+ if err := app.validate(); err == nil {
+ t.Fatal("Negative or zero MaxNickLen should not be valid.")
+ }
+
+ //MenuMaxIdleTime
+ t.Cleanup(func() { os.RemoveAll("var/") })
+ app = App{
+ WorkingDirectory: "var/",
+ MaxUsers: 512,
+ MaxNickLen: 15,
+ MenuMaxIdleTime: 0,
+ }
+ if err := app.validate(); err == nil {
+ t.Fatal("Negative or zero MenuMaxIdleTime should not be valid.")
+ }
+
+ //PostLoginCommands are mostly tested from command_test.go
+ app = App{
+ WorkingDirectory: "var/",
+ MaxUsers: 512,
+ MaxNickLen: 15,
+ MenuMaxIdleTime: 60,
+ }
+ if err := app.validate(); err != nil {
+ t.Fatal("Empty PostLoginCommands list should be valid")
+ }
+ app = App{
+ WorkingDirectory: "var/",
+ MaxUsers: 512,
+ MaxNickLen: 15,
+ MenuMaxIdleTime: 60,
+ PostLoginCommands: []string{"invalid"},
+ }
+ if err := app.validate(); err == nil {
+ t.Fatal("Invalid command in PostLoginCommands should not be valid")
+ }
+
+ // A valid App
+ app = App{
+ WorkingDirectory: "var/",
+ MaxUsers: 512,
+ MaxNickLen: 15,
+ MenuMaxIdleTime: 60,
+ PostLoginCommands: []string{"wait"},
+ }
+ if err := app.validate(); err != nil {
+ t.Fatal("A valid app should pass")
+ }
+}
diff --git a/pkg/config/command.go b/pkg/config/command.go
new file mode 100644
index 0000000..34142cd
--- /dev/null
+++ b/pkg/config/command.go
@@ -0,0 +1,32 @@
+package config
+
+import (
+ "strings"
+
+ "github.com/pkg/errors"
+)
+
+func validateCommand(cmd string) error {
+ tokens := strings.Split(cmd, " ")
+ switch tokens[0] {
+ case "cp":
+ if len(tokens) != 3 {
+ return errors.New("cp command takes exactly two arguments")
+ }
+ case "exec":
+ if len(tokens) <= 1 {
+ return errors.New("exec command needs arguments")
+ }
+ case "mkdir":
+ if len(tokens) != 2 {
+ return errors.New("mkdir command takes exactly one argument")
+ }
+ case "wait":
+ if len(tokens) != 1 {
+ return errors.New("wait command takes no arguments")
+ }
+ default:
+ return errors.New("Invalid command : " + tokens[0])
+ }
+ return nil
+}
diff --git a/pkg/config/command_test.go b/pkg/config/command_test.go
new file mode 100644
index 0000000..7e07956
--- /dev/null
+++ b/pkg/config/command_test.go
@@ -0,0 +1,51 @@
+package config
+
+import "testing"
+
+func TestCommandValidate(t *testing.T) {
+ // Empty command
+ if err := validateCommand(""); err == nil {
+ t.Fatal("An command cannot be empty")
+ }
+ // invalid command
+ if err := validateCommand("invalid"); err == nil {
+ t.Fatal("An command cannot be empty")
+ }
+ // cp
+ if err := validateCommand("cp"); err == nil {
+ t.Fatal("cp command needs arguments")
+ }
+ if err := validateCommand("cp test"); err == nil {
+ t.Fatal("cp command needs exactly 2 arguments")
+ }
+ if err := validateCommand("cp test test test"); err == nil {
+ t.Fatal("cp command needs exactly 2 arguments")
+ }
+ if err := validateCommand("exec test test"); err != nil {
+ t.Fatal("valid exec command should be accepted")
+ }
+ // exec
+ if err := validateCommand("exec"); err == nil {
+ t.Fatal("exec command needs arguments")
+ }
+ if err := validateCommand("exec test"); err != nil {
+ t.Fatal("valid exec command should be accepted")
+ }
+ // mkdir
+ if err := validateCommand("mkdir"); err == nil {
+ t.Fatal("mkdir command needs exactly 1 argument")
+ }
+ if err := validateCommand("mkdir testtest "); err == nil {
+ t.Fatal("mkdir command needs exactly 1 argument")
+ }
+ if err := validateCommand("mkdir test"); err != nil {
+ t.Fatal("valid mkdir command should be accepted")
+ }
+ // wait
+ if err := validateCommand("wait test"); err == nil {
+ t.Fatal("wait command needs no arguments")
+ }
+ if err := validateCommand("wait"); err != nil {
+ t.Fatal("valid wait command should be accepted")
+ }
+}
diff --git a/pkg/config/config.go b/pkg/config/config.go
new file mode 100644
index 0000000..b68a26a
--- /dev/null
+++ b/pkg/config/config.go
@@ -0,0 +1,64 @@
+package config
+
+import (
+ "os"
+
+ "github.com/pkg/errors"
+ "gopkg.in/yaml.v3"
+)
+
+type Config struct {
+ // AppConfig is the application level configuration entries
+ App App `yaml:"App"`
+ // Menus is the list of menus. The first one is the default menu for an anonymous user, the second one is the default menu for an authenticated user
+ Menus map[string]Menu `yaml:"Menus"`
+ // Games is the list of games.
+ Games map[string]Game `yaml:"Games"`
+}
+
+func (c *Config) validate() error {
+ // App
+ if err := c.App.validate(); err != nil {
+ return err
+ }
+ // Menus
+ if len(c.Menus) < 2 {
+ return errors.New("A valid configuration needs at least two menu entries named anonymous and logged_in")
+ }
+ for k, v := range c.Menus {
+ if err := v.validate(k); err != nil {
+ return err
+ }
+ }
+ // Games
+ for k, v := range c.Games {
+ if err := v.validate(k); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+// LoadFile loads the c from a given file
+func LoadFile(path string) (*Config, error) {
+ var c *Config
+ f, errOpen := os.Open(path)
+ if errOpen != nil {
+ return nil, errors.Wrapf(errOpen, "Failed to open configuration file %s", path)
+ }
+ defer f.Close()
+ decoder := yaml.NewDecoder(f)
+ if err := decoder.Decode(&c); err != nil {
+ return nil, errors.Wrap(err, "Failed to decode configuration file")
+ }
+ if err := c.validate(); err != nil {
+ return nil, errors.Wrap(err, "Failed to validate configuration")
+ }
+ // If all looks good we validate menu consistency
+ for _, v := range c.Menus {
+ if err := v.validateConsistency(c); err != nil {
+ return nil, errors.Wrap(err, "Failed menu consistency checks")
+ }
+ }
+ return c, nil
+}
diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go
new file mode 100644
index 0000000..8eb6598
--- /dev/null
+++ b/pkg/config/config_test.go
@@ -0,0 +1,221 @@
+package config
+
+import (
+ "os"
+ "reflect"
+ "testing"
+)
+
+func TestLoadFile(t *testing.T) {
+ // Non existant file
+ _, err := LoadFile("test_data/non-existant")
+ if err == nil {
+ t.Fatal("non-existant config file failed without error")
+ }
+
+ // Invalid yaml file
+ _, err = LoadFile("test_data/invalid_yaml")
+ if err == nil {
+ t.Fatal("invalid_yaml config file failed without error")
+ }
+
+ // Minimal yaml file
+ want := Config{
+ App: App{
+ WorkingDirectory: "var/",
+ MaxUsers: 1,
+ AllowRegistration: true,
+ MaxNickLen: 15,
+ MenuMaxIdleTime: 600,
+ },
+ Menus: map[string]Menu{
+ "anonymous": Menu{
+ MenuEntries: []MenuEntry{
+ MenuEntry{
+ Key: "q",
+ Label: "quit",
+ Action: "quit",
+ },
+ },
+ },
+ "logged_in": Menu{
+ MenuEntries: []MenuEntry{
+ MenuEntry{
+ Key: "q",
+ Label: "quit",
+ Action: "quit",
+ },
+ },
+ },
+ },
+ }
+ config, err := LoadFile("test_data/minimal.yaml")
+ if err != nil {
+ t.Fatalf("minimal example failed with error : %v", err)
+ }
+ if config != nil && !reflect.DeepEqual(want, *config) {
+ t.Fatalf("minimal example failed:\nwant:%+v\ngot: %+v", want, *config)
+ }
+ t.Cleanup(func() { os.RemoveAll("var/") })
+ // Invalid App
+ if _, err := LoadFile("test_data/invalid_app.yaml"); err == nil {
+ t.Fatal("Invalid App entry should fail to load")
+ }
+ // Not enough menus
+ if _, err := LoadFile("test_data/not_enough_menus.yaml"); err == nil {
+ t.Fatal("not enough menu entries should fail to load")
+ }
+ // Invalid Menus
+ if _, err := LoadFile("test_data/invalid_menus.yaml"); err == nil {
+ t.Fatal("Invalid menu entry should fail to load")
+ }
+ // no anonymous Menu
+ if _, err := LoadFile("test_data/no_anonymous_menu.yaml"); err == nil {
+ t.Fatal("Invalid menu entry should fail to load")
+ }
+ // no logged_in Menu
+ if _, err := LoadFile("test_data/no_logged_in_menu.yaml"); err == nil {
+ t.Fatal("Invalid menu entry should fail to load")
+ }
+ // duplicate menu
+ if _, err := LoadFile("test_data/duplicate_menu.yaml"); err == nil {
+ t.Fatal("duplicate menu should fail to load")
+ }
+ // non existant menu action referenced
+ if _, err := LoadFile("test_data/non_existant_menu.yaml"); err == nil {
+ t.Fatal("menu entry referencing a non existant menu should fail to load")
+ }
+ // non existant game referenced in play action
+ if _, err := LoadFile("test_data/non_existant_game.yaml"); err == nil {
+ t.Fatal("menu entry referencing a non existant play action should fail to load")
+ }
+ // unreachable menu
+ if _, err := LoadFile("test_data/unreachable_menu.yaml"); err == nil {
+ t.Fatal("unreachable menu should fail to load")
+ }
+ // invalid game
+ if _, err := LoadFile("test_data/invalid_game.yaml"); err == nil {
+ t.Fatal("invalid game should fail to load")
+ }
+ // unreachable game
+ if _, err := LoadFile("test_data/unreachable_game.yaml"); err == nil {
+ t.Fatal("unreachable game should fail to load")
+ }
+ // duplicate game
+ if _, err := LoadFile("test_data/duplicate_game.yaml"); err == nil {
+ t.Fatal("unreachable game should fail to load")
+ }
+
+ // Complexe example
+ want = Config{
+ App: App{
+ WorkingDirectory: "var/",
+ MaxUsers: 512,
+ AllowRegistration: true,
+ MaxNickLen: 15,
+ MenuMaxIdleTime: 600,
+ PostLoginCommands: []string{
+ "mkdir %w/userdata/%u",
+ "mkdir %w/userdata/%u/dumplog",
+ "mkdir %w/userdata/%u/ttyrec",
+ },
+ },
+ Menus: map[string]Menu{
+ "anonymous": Menu{
+ Banner: "Shell Game Launcher - Anonymous access%n======================================",
+ MenuEntries: []MenuEntry{
+ MenuEntry{
+ Key: "l",
+ Label: "login",
+ Action: "login",
+ },
+ MenuEntry{
+ Key: "r",
+ Label: "register",
+ Action: "register",
+ },
+ MenuEntry{
+ Key: "w",
+ Label: "watch",
+ Action: "watch_menu",
+ },
+ MenuEntry{
+ Key: "q",
+ Label: "quit",
+ Action: "quit",
+ },
+ },
+ },
+ "logged_in": Menu{
+ Banner: "Shell Game Launcher%n===================",
+ MenuEntries: []MenuEntry{
+ MenuEntry{
+ Key: "p",
+ Label: "play Nethack 3.7",
+ Action: "play nethack3.7",
+ },
+ MenuEntry{
+ Key: "o",
+ Label: "edit game options",
+ Action: "menu options",
+ },
+ MenuEntry{
+ Key: "w",
+ Label: "watch",
+ Action: "watch",
+ },
+ MenuEntry{
+ Key: "r",
+ Label: "replay",
+ Action: "replay",
+ },
+ MenuEntry{
+ Key: "c",
+ Label: "change password",
+ Action: "passwd",
+ },
+ MenuEntry{
+ Key: "m",
+ Label: "change email",
+ Action: "chmail",
+ },
+ MenuEntry{
+ Key: "q",
+ Label: "quit",
+ Action: "quit",
+ },
+ },
+ },
+ "options": Menu{
+ Banner: "Options%n=======",
+ MenuEntries: []MenuEntry{
+ MenuEntry{
+ Key: "z",
+ Label: "back",
+ Action: "menu logged_in",
+ },
+ },
+ },
+ },
+ Games: map[string]Game{
+ "nethack3.7": Game{
+ ChrootPath: "test_data/fake_nethack_directory",
+ FileMode: "0666",
+ Commands: []string{
+ "cp /games/var/save/%u%n.gz /games/var/save/%u%n.gz.bak",
+ "exec /games/nethack -u %n",
+ },
+ Env: map[string]string{
+ "NETHACKOPTIONS": "@%ruserdata/%n/%n.nhrc",
+ },
+ },
+ },
+ }
+ config, err = LoadFile("../../example/complete.yaml")
+ if err != nil {
+ t.Fatalf("complete example failed with error : %v", err)
+ }
+ if config != nil && !reflect.DeepEqual(want, *config) {
+ t.Fatalf("complete example failed:\nwant:%+v\ngot: %+v", want, *config)
+ }
+}
diff --git a/pkg/config/game.go b/pkg/config/game.go
new file mode 100644
index 0000000..b5f4d39
--- /dev/null
+++ b/pkg/config/game.go
@@ -0,0 +1,63 @@
+package config
+
+import (
+ "regexp"
+
+ "github.com/pkg/errors"
+ "golang.org/x/sys/unix"
+)
+
+var reValidGameName = regexp.MustCompile(`^[\w\._]+$`)
+var reValidFileMode = regexp.MustCompile(`^0[\d]{3}$`)
+var reSpace = regexp.MustCompile(`^\s$`)
+
+// Game struct containers the configuration for a game
+type Game struct {
+ // ChrootPath is the chroot path for the game
+ ChrootPath string `yaml:"ChrootPath"`
+ // FileMode is the file mode to use when copying files
+ FileMode string `yaml:"FileMode"`
+ // Commands is the command list
+ Commands []string `yaml:"Commands"`
+ // Env is the environment in which to exec the commands
+ Env map[string]string `yaml:"Env"`
+}
+
+func (g *Game) validate(name string) error {
+ // Game name
+ if ok := reValidGameName.MatchString(name); !ok {
+ return errors.New("Invalid Game name, must match regex `^[\\w\\._]+$` : " + name)
+ }
+ // ChrootPath TODO
+ if err := unix.Access(g.ChrootPath, unix.R_OK|unix.X_OK); err != nil {
+ return errors.Wrapf(err, "Invalid ChrootPath : %s", g.ChrootPath)
+ }
+ // FileMode
+ if ok := reValidFileMode.MatchString(g.FileMode); !ok {
+ return errors.New("Invalid File Mode, must match regex `^0[\\d]{3}$` : " + name)
+ }
+ // Commands
+ if len(g.Commands) == 0 {
+ return errors.New("Invalid game " + name + " has no commands")
+ }
+ for i := 0; i < len(g.Commands); i++ {
+ if err := validateCommand(g.Commands[i]); err != nil {
+ return errors.Wrapf(err, "Failed to validate Commands for game %s", name)
+ }
+ }
+ // Env
+ for k, _ := range g.Env {
+ for _, c := range k {
+ switch c {
+ case '=':
+ return errors.New("Environment variable key must not contain equal sign")
+ case '\000':
+ return errors.New("Environment variable key must not contain null character")
+ }
+ if reSpace.MatchString(string(c)) {
+ return errors.New("Environment variable key must not contain spaces")
+ }
+ }
+ }
+ return nil
+}
diff --git a/pkg/config/game_test.go b/pkg/config/game_test.go
new file mode 100644
index 0000000..b3bf107
--- /dev/null
+++ b/pkg/config/game_test.go
@@ -0,0 +1,107 @@
+package config
+
+import "testing"
+
+func TestGameValidate(t *testing.T) {
+ // Game name
+ game := Game{}
+ if err := game.validate("invalid game name because of spaces"); err == nil {
+ t.Fatal("game name with spaces should not be valid")
+ }
+ // ChrootPath
+ game = Game{ChrootPath: "test_data/non_existant"}
+ if err := game.validate("test"); err == nil {
+ t.Fatal("non_existant ChrootPath should not be valid")
+ }
+ // FileMode
+ game = Game{
+ ChrootPath: "test_data/fake_nethack_directory",
+ }
+ if err := game.validate("test"); err == nil {
+ t.Fatal("Invalid FileMode should not be valid")
+ }
+ game = Game{
+ ChrootPath: "test_data/fake_nethack_directory",
+ FileMode: "abcd",
+ }
+ if err := game.validate("test"); err == nil {
+ t.Fatal("Invalid FileMode should not be valid")
+ }
+ game = Game{
+ ChrootPath: "test_data/fake_nethack_directory",
+ FileMode: "777",
+ }
+ if err := game.validate("test"); err == nil {
+ t.Fatal("Invalid FileMode should not be valid")
+ }
+ // Commands are mostly tested from command_test.go
+ game = Game{
+ ChrootPath: "test_data/fake_nethack_directory",
+ FileMode: "0777",
+ }
+ if err := game.validate("test"); err == nil {
+ t.Fatal("Empty Commands list should not be valid")
+ }
+ game = Game{
+ ChrootPath: "test_data/fake_nethack_directory",
+ FileMode: "0777",
+ Commands: []string{"invalid"},
+ }
+ if err := game.validate("test"); err == nil {
+ t.Fatal("Invalid command in Commands should not be valid")
+ }
+ // Env
+ game = Game{
+ ChrootPath: "test_data/fake_nethack_directory",
+ FileMode: "0777",
+ Commands: []string{"wait"},
+ }
+ if err := game.validate("test"); err != nil {
+ t.Fatal("Empty env list should be valid")
+ }
+ game = Game{
+ ChrootPath: "test_data/fake_nethack_directory",
+ FileMode: "0777",
+ Commands: []string{"wait"},
+ Env: map[string]string{
+ "test invalid": "test",
+ },
+ }
+ if err := game.validate("test"); err == nil {
+ t.Fatal("Spaces in environnement variable name are invalid")
+ }
+ game = Game{
+ ChrootPath: "test_data/fake_nethack_directory",
+ FileMode: "0777",
+ Commands: []string{"wait"},
+ Env: map[string]string{
+ "test\000invalid": "test",
+ },
+ }
+ if err := game.validate("test"); err == nil {
+ t.Fatal("null character in environnement variable name are invalid")
+ }
+ game = Game{
+ ChrootPath: "test_data/fake_nethack_directory",
+ FileMode: "0777",
+ Commands: []string{"wait"},
+ Env: map[string]string{
+ "test=invalid": "test",
+ },
+ }
+ if err := game.validate("test"); err == nil {
+ t.Fatal("equals symbol in environnement variable name are invalid")
+ }
+ // Valid Game entry
+ game = Game{
+ ChrootPath: "test_data/fake_nethack_directory",
+ FileMode: "0777",
+ Commands: []string{"wait"},
+ Env: map[string]string{
+ "test": "test",
+ },
+ }
+ if err := game.validate("test"); err != nil {
+ t.Fatalf("Valid game entry should pass but got error %s", err)
+ }
+}
diff --git a/pkg/config/menu.go b/pkg/config/menu.go
new file mode 100644
index 0000000..7321c7b
--- /dev/null
+++ b/pkg/config/menu.go
@@ -0,0 +1,113 @@
+package config
+
+import (
+ "regexp"
+ "strings"
+
+ "github.com/pkg/errors"
+)
+
+var reValidMenuName = regexp.MustCompile(`^[\w\._]+$`)
+var reValidKey = regexp.MustCompile(`^\w$`)
+
+// Menu struct describes a screen menu
+type Menu struct {
+ // Banner is the banner to display before the menu
+ Banner string `yaml:"Banner"`
+ // Commands is the list of commands in the menu
+ MenuEntries []MenuEntry `yaml:"MenuEntries"`
+}
+
+// MenuEntry struct describes a menu entry
+type MenuEntry struct {
+ // Key is the key associated with the action. We need to store it as a string because of how yaml unmarshal works
+ Key string `yaml:"Key"`
+ // Label is the text displayed on the menu
+ Label string `yaml:"Label"`
+ // Action is the action executed when the menu entry is selected
+ Action string `yaml:"Action"`
+}
+
+func (m *Menu) validate(name string) error {
+ // validate name
+ if ok := reValidMenuName.MatchString(name); !ok {
+ return errors.New("Invalid menu name, must be an alphanumeric word and match regex `^[\\w\\._]+$` : " + name)
+ }
+ // Banner is just any string, nothing to validate
+ // MenuEntries
+ if len(m.MenuEntries) == 0 {
+ return errors.New("A Menu needs MenuEntries to be valid")
+ }
+ // Duplicate detection is natively handled by the yaml parser
+ for i := 0; i < len(m.MenuEntries); i++ {
+ m.MenuEntries[i].validate()
+ if m.MenuEntries[i].Action == "menu "+name {
+ return errors.New("A menu shall not loop on itself")
+ }
+ }
+ // Loop test
+ return nil
+}
+
+func (m *Menu) validateConsistency(c *Config) error {
+ // Necessary menus
+ if _, ok := c.Menus["anonymous"]; !ok {
+ return errors.New("No anonymous menu declared")
+ }
+ if _, ok := c.Menus["logged_in"]; !ok {
+ return errors.New("No logged_in menu declared")
+ }
+ // Validate actions
+ menus := map[string]bool{
+ "anonymous": true,
+ "logged_in": true,
+ }
+ playable := make(map[string]bool)
+ for k, v := range c.Menus {
+ for _, e := range v.MenuEntries {
+ tokens := strings.Split(e.Action, " ")
+ switch tokens[0] {
+ case "menu":
+ if _, ok := c.Menus[tokens[1]]; ok {
+ menus[tokens[1]] = true
+ } else {
+ return errors.New("menu action " + tokens[1] + " in menu " + k + " does not exist")
+ }
+ case "play":
+ if _, ok := c.Games[tokens[1]]; ok {
+ playable[tokens[1]] = true
+ } else {
+ return errors.New("play action " + tokens[1] + " in menu " + k + " does not exist")
+ }
+ }
+ }
+ }
+ // Check for unreachables
+ for k, _ := range c.Menus {
+ if _, ok := menus[k]; !ok {
+ return errors.New("unreachable menu : " + k)
+ }
+ }
+ for k, _ := range c.Games {
+ if _, ok := playable[k]; !ok {
+ return errors.New("unplayable game : " + k)
+ }
+ }
+ return nil
+}
+
+func (m *MenuEntry) validate() error {
+ // Key
+ if ok := reValidKey.MatchString(m.Key); !ok {
+ return errors.New("Invalid Key, must be exactly one alphanumeric character and match regex `^\\w$` : " + m.Key)
+ }
+ // Label
+ if len(m.Label) <= 0 {
+ return errors.New("Invalid Label, cannot be empty")
+ }
+ // Action
+ if err := validateAction(m.Action); err != nil {
+ return errors.Wrap(err, "Invalid Action in MenuEntry")
+ }
+ return nil
+}
diff --git a/pkg/config/menu_test.go b/pkg/config/menu_test.go
new file mode 100644
index 0000000..e9abef1
--- /dev/null
+++ b/pkg/config/menu_test.go
@@ -0,0 +1,71 @@
+package config
+
+import "testing"
+
+func TestMenuValidate(t *testing.T) {
+ // menu name
+ menu := Menu{}
+ if err := menu.validate(""); err == nil {
+ t.Fatal("Empty menu name is not valid")
+ }
+ if err := menu.validate("test test"); err == nil {
+ t.Fatal("non alphanumeric menu name is not valid")
+ }
+ // Banner is just any string, nothing to validate
+ // MenuEntries are mostly tested bellow
+ menu = Menu{}
+ if err := menu.validate("test"); err == nil {
+ t.Fatal("A menu without menu entries should not be valid")
+ }
+ // loop menu
+ menu = Menu{
+ MenuEntries: []MenuEntry{
+ MenuEntry{
+ Key: "a",
+ Label: "test",
+ Action: "menu test",
+ },
+ },
+ }
+ if err := menu.validate("test"); err == nil {
+ t.Fatal("A menu should not be able to loop on itself")
+ }
+ // A valid menu
+ menu = Menu{
+ MenuEntries: []MenuEntry{
+ MenuEntry{
+ Key: "a",
+ Label: "test",
+ Action: "quit",
+ },
+ },
+ }
+ if err := menu.validate("test"); err != nil {
+ t.Fatal("A valid menu should pass")
+ }
+}
+
+func TestMenuEntryValidate(t *testing.T) {
+ // Key
+ menuEntry := MenuEntry{}
+ if err := menuEntry.validate(); err == nil {
+ t.Fatal("A Key cannot be empty")
+ }
+ menuEntry = MenuEntry{Key: "ab"}
+ if err := menuEntry.validate(); err == nil {
+ t.Fatal("A Key should be only one character")
+ }
+ menuEntry = MenuEntry{Key: " "}
+ if err := menuEntry.validate(); err == nil {
+ t.Fatal("A Key should be a printable character")
+ }
+ // Label
+ menuEntry = MenuEntry{
+ Key: "l",
+ Label: "",
+ }
+ if err := menuEntry.validate(); err == nil {
+ t.Fatal("A Label cannot be empty")
+ }
+ // Actions are tested in action_test.go
+}
diff --git a/pkg/config/test_data/duplicate_game.yaml b/pkg/config/test_data/duplicate_game.yaml
new file mode 100644
index 0000000..f01a017
--- /dev/null
+++ b/pkg/config/test_data/duplicate_game.yaml
@@ -0,0 +1,30 @@
+App:
+ WorkingDirectory: var/
+ MaxUsers: 1
+ AllowRegistration: true
+ MaxNickLen: 15
+ MenuMaxIdleTime: 600
+
+Menus:
+ anonymous:
+ MenuEntries:
+ - Key: q
+ Label: quit
+ Action: quit
+ logged_in:
+ MenuEntries:
+ - Key: p
+ Label: play
+ Action: play test
+
+Games:
+ test:
+ ChrootPath: test_data/fake_nethack_directory
+ FileMode: 0777
+ Commands:
+ - wait
+ test:
+ ChrootPath: test_data/fake_nethack_directory
+ FileMode: 0777
+ Commands:
+ - wait
diff --git a/pkg/config/test_data/duplicate_menu.yaml b/pkg/config/test_data/duplicate_menu.yaml
new file mode 100644
index 0000000..3dbefb7
--- /dev/null
+++ b/pkg/config/test_data/duplicate_menu.yaml
@@ -0,0 +1,28 @@
+App:
+ WorkingDirectory: var/
+ MaxUsers: 1
+ AllowRegistration: true
+ MaxNickLen: 15
+ MenuMaxIdleTime: 600
+
+Menus:
+ anonymous:
+ MenuEntries:
+ - Key: t
+ Label: test
+ Action: menu test
+ logged_in:
+ MenuEntries:
+ - Key: q
+ Label: quit
+ Action: quit
+ test:
+ MenuEntries:
+ - Key: q
+ Label: quit
+ Action: quit
+ test:
+ MenuEntries:
+ - Key: a
+ Label: login
+ Action: login
diff --git a/pkg/config/test_data/fake_nethack_directory/.keep b/pkg/config/test_data/fake_nethack_directory/.keep
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/pkg/config/test_data/fake_nethack_directory/.keep
diff --git a/pkg/config/test_data/invalid_app.yaml b/pkg/config/test_data/invalid_app.yaml
new file mode 100644
index 0000000..ed236ea
--- /dev/null
+++ b/pkg/config/test_data/invalid_app.yaml
@@ -0,0 +1,11 @@
+Menus:
+ anonymous:
+ MenuEntries:
+ - Key: q
+ Label: quit
+ Action: quit
+ logged_in:
+ MenuEntries:
+ - Key: q
+ Label: quit
+ Action: quit
diff --git a/pkg/config/test_data/invalid_game.yaml b/pkg/config/test_data/invalid_game.yaml
new file mode 100644
index 0000000..d58c3ee
--- /dev/null
+++ b/pkg/config/test_data/invalid_game.yaml
@@ -0,0 +1,21 @@
+App:
+ WorkingDirectory: var/
+ MaxUsers: 1
+ AllowRegistration: true
+ MaxNickLen: 15
+ MenuMaxIdleTime: 600
+
+Menus:
+ anonymous:
+ MenuEntries:
+ - Key: q
+ Label: quit
+ Action: quit
+ logged_in:
+ MenuEntries:
+ - Key: p
+ Label: play
+ Action: play test
+
+Games:
+ test:
diff --git a/pkg/config/test_data/invalid_menus.yaml b/pkg/config/test_data/invalid_menus.yaml
new file mode 100644
index 0000000..1df5fbf
--- /dev/null
+++ b/pkg/config/test_data/invalid_menus.yaml
@@ -0,0 +1,19 @@
+App:
+ WorkingDirectory: var/
+ MaxUsers: 1
+ AllowRegistration: true
+ MaxNickLen: 15
+ MenuMaxIdleTime: 600
+
+Menus:
+ anonymous:
+ MenuEntries:
+ - Key: q
+ Label: quit
+ Action: quit
+ logged_in:
+ MenuEntries:
+ - Key: q
+ Label: quit
+ Action: quit
+ test:
diff --git a/pkg/config/test_data/invalid_yaml b/pkg/config/test_data/invalid_yaml
new file mode 100644
index 0000000..db1ddad
--- /dev/null
+++ b/pkg/config/test_data/invalid_yaml
@@ -0,0 +1 @@
+blargh(ads)
diff --git a/pkg/config/test_data/minimal.yaml b/pkg/config/test_data/minimal.yaml
new file mode 100644
index 0000000..22a0a6c
--- /dev/null
+++ b/pkg/config/test_data/minimal.yaml
@@ -0,0 +1,18 @@
+App:
+ WorkingDirectory: var/
+ MaxUsers: 1
+ AllowRegistration: true
+ MaxNickLen: 15
+ MenuMaxIdleTime: 600
+
+Menus:
+ anonymous:
+ MenuEntries:
+ - Key: q
+ Label: quit
+ Action: quit
+ logged_in:
+ MenuEntries:
+ - Key: q
+ Label: quit
+ Action: quit
diff --git a/pkg/config/test_data/no_anonymous_menu.yaml b/pkg/config/test_data/no_anonymous_menu.yaml
new file mode 100644
index 0000000..a015160
--- /dev/null
+++ b/pkg/config/test_data/no_anonymous_menu.yaml
@@ -0,0 +1,18 @@
+App:
+ WorkingDirectory: var/
+ MaxUsers: 1
+ AllowRegistration: true
+ MaxNickLen: 15
+ MenuMaxIdleTime: 600
+
+Menus:
+ test:
+ MenuEntries:
+ - Key: q
+ Label: quit
+ Action: quit
+ logged_in:
+ MenuEntries:
+ - Key: q
+ Label: quit
+ Action: quit
diff --git a/pkg/config/test_data/no_logged_in_menu.yaml b/pkg/config/test_data/no_logged_in_menu.yaml
new file mode 100644
index 0000000..43d0054
--- /dev/null
+++ b/pkg/config/test_data/no_logged_in_menu.yaml
@@ -0,0 +1,18 @@
+App:
+ WorkingDirectory: var/
+ MaxUsers: 1
+ AllowRegistration: true
+ MaxNickLen: 15
+ MenuMaxIdleTime: 600
+
+Menus:
+ anonymous:
+ MenuEntries:
+ - Key: q
+ Label: quit
+ Action: quit
+ test:
+ MenuEntries:
+ - Key: q
+ Label: quit
+ Action: quit
diff --git a/pkg/config/test_data/non_existant_chopts.yaml b/pkg/config/test_data/non_existant_chopts.yaml
new file mode 100644
index 0000000..d3f796d
--- /dev/null
+++ b/pkg/config/test_data/non_existant_chopts.yaml
@@ -0,0 +1,18 @@
+App:
+ WorkingDirectory: var/
+ MaxUsers: 1
+ AllowRegistration: true
+ MaxNickLen: 15
+ MenuMaxIdleTime: 600
+
+Menus:
+ anonymous:
+ MenuEntries:
+ - Key: q
+ Label: quit
+ Action: quit
+ logged_in:
+ MenuEntries:
+ - Key: t
+ Label: test
+ Action: chopts invalid
diff --git a/pkg/config/test_data/non_existant_game.yaml b/pkg/config/test_data/non_existant_game.yaml
new file mode 100644
index 0000000..9bf6a38
--- /dev/null
+++ b/pkg/config/test_data/non_existant_game.yaml
@@ -0,0 +1,18 @@
+App:
+ WorkingDirectory: var/
+ MaxUsers: 1
+ AllowRegistration: true
+ MaxNickLen: 15
+ MenuMaxIdleTime: 600
+
+Menus:
+ anonymous:
+ MenuEntries:
+ - Key: q
+ Label: quit
+ Action: quit
+ logged_in:
+ MenuEntries:
+ - Key: t
+ Label: test
+ Action: play invalid
diff --git a/pkg/config/test_data/non_existant_menu.yaml b/pkg/config/test_data/non_existant_menu.yaml
new file mode 100644
index 0000000..f0f30a3
--- /dev/null
+++ b/pkg/config/test_data/non_existant_menu.yaml
@@ -0,0 +1,18 @@
+App:
+ WorkingDirectory: var/
+ MaxUsers: 1
+ AllowRegistration: true
+ MaxNickLen: 15
+ MenuMaxIdleTime: 600
+
+Menus:
+ anonymous:
+ MenuEntries:
+ - Key: t
+ Label: test
+ Action: menu invalid
+ logged_in:
+ MenuEntries:
+ - Key: q
+ Label: quit
+ Action: quit
diff --git a/pkg/config/test_data/not_enough_menus.yaml b/pkg/config/test_data/not_enough_menus.yaml
new file mode 100644
index 0000000..f585f44
--- /dev/null
+++ b/pkg/config/test_data/not_enough_menus.yaml
@@ -0,0 +1,13 @@
+App:
+ WorkingDirectory: var/
+ MaxUsers: 1
+ AllowRegistration: true
+ MaxNickLen: 15
+ MenuMaxIdleTime: 600
+
+Menus:
+ test:
+ MenuEntries:
+ - Key: q
+ Label: quit
+ Action: quit
diff --git a/pkg/config/test_data/unreachable_game.yaml b/pkg/config/test_data/unreachable_game.yaml
new file mode 100644
index 0000000..f2f22e2
--- /dev/null
+++ b/pkg/config/test_data/unreachable_game.yaml
@@ -0,0 +1,25 @@
+App:
+ WorkingDirectory: var/
+ MaxUsers: 1
+ AllowRegistration: true
+ MaxNickLen: 15
+ MenuMaxIdleTime: 600
+
+Menus:
+ anonymous:
+ MenuEntries:
+ - Key: q
+ Label: quit
+ Action: quit
+ logged_in:
+ MenuEntries:
+ - Key: q
+ Label: quit
+ Action: quit
+
+Games:
+ unreachable:
+ ChrootPath: test_data/fake_nethack_directory
+ FileMode: 0777
+ Commands:
+ - wait
diff --git a/pkg/config/test_data/unreachable_menu.yaml b/pkg/config/test_data/unreachable_menu.yaml
new file mode 100644
index 0000000..f947cf7
--- /dev/null
+++ b/pkg/config/test_data/unreachable_menu.yaml
@@ -0,0 +1,23 @@
+App:
+ WorkingDirectory: var/
+ MaxUsers: 1
+ AllowRegistration: true
+ MaxNickLen: 15
+ MenuMaxIdleTime: 600
+
+Menus:
+ anonymous:
+ MenuEntries:
+ - Key: q
+ Label: quit
+ Action: quit
+ logged_in:
+ MenuEntries:
+ - Key: q
+ Label: quit
+ Action: quit
+ test:
+ MenuEntries:
+ - Key: q
+ Label: quit
+ Action: quit