diff options
Diffstat (limited to 'pkg')
34 files changed, 1537 insertions, 0 deletions
diff --git a/pkg/client/input.go b/pkg/client/input.go new file mode 100644 index 0000000..8c814a6 --- /dev/null +++ b/pkg/client/input.go @@ -0,0 +1,26 @@ +package client + +import ( + "bufio" + "os" + + "github.com/pkg/errors" +) + +// getValidInput returns the selected menu command as a string or an error +func (s *State) getValidInput() (string, error) { + menu := s.config.Menus[s.currentMenu] + + reader := bufio.NewReader(os.Stdin) + for { + input, err := reader.ReadByte() + if err != nil { + return "", errors.Wrapf(err, "Could not read byte from stdin") + } + for _, menuEntry := range menu.MenuEntries { + if []byte(menuEntry.Key)[0] == input { + return menuEntry.Action, nil + } + } + } +} diff --git a/pkg/client/input_test.go b/pkg/client/input_test.go new file mode 100644 index 0000000..f74a097 --- /dev/null +++ b/pkg/client/input_test.go @@ -0,0 +1,60 @@ +package client + +import ( + "os" + "shell-game-launcher/pkg/config" + "testing" +) + +func TestGetValidInput(t *testing.T) { + realStdin := os.Stdin + t.Cleanup(func() { os.Stdin = realStdin }) + + // Complete menu, no input error + state := State{ + config: &config.Config{ + Menus: map[string]config.Menu{ + "test": config.Menu{ + Banner: "TEST TEST TEST", + MenuEntries: []config.MenuEntry{ + config.MenuEntry{ + Key: "w", + Label: "wait entry", + Action: "wait", + }, + config.MenuEntry{ + Key: "q", + Label: "quit entry", + Action: "quit", + }, + }, + }, + }, + }, + currentMenu: "test", + login: "", + } + r, w, _ := os.Pipe() + os.Stdin = r + + // Simply test quit entry + w.WriteString("q") + if cmd, err := state.getValidInput(); err != nil || cmd != "quit" { + t.Fatalf("Input handled incorrectly:\nwant: wait\ngot: %s\nerror: %s\n", cmd, err) + } + // test quit entry after wrong keys + w.WriteString("abcdq") + if cmd, err := state.getValidInput(); err != nil || cmd != "quit" { + t.Fatalf("Input handled incorrectly:\nwant: wait\ngot: %s\nerror: %s\n", cmd, err) + } + // test wait entry with valid quit after + w.WriteString("wq") + if cmd, err := state.getValidInput(); err != nil || cmd != "wait" { + t.Fatalf("Input handled incorrectly:\nwant: wait\ngot: %s\nerror: %s\n", cmd, err) + } + // test input error + w.Close() + if cmd, err := state.getValidInput(); err == nil { + t.Fatalf("Input handled incorrectly:\nwant: wait\ngot: %s\nerror: %s\n", cmd, err) + } +} diff --git a/pkg/client/menu.go b/pkg/client/menu.go new file mode 100644 index 0000000..f6de082 --- /dev/null +++ b/pkg/client/menu.go @@ -0,0 +1,17 @@ +package client + +import "fmt" + +func (s *State) displayMenu() { + menu := s.config.Menus[s.currentMenu] + fmt.Print("\033[2J") // clear the screen + fmt.Printf("%s\n\n", menu.Banner) + if s.login == "" { + fmt.Print("Not logged in.\n\n") + } else { + fmt.Printf("Logged in as: %s\n\n", s.login) + } + for i := 0; i < len(menu.MenuEntries); i++ { + fmt.Printf("%s) %s\n", menu.MenuEntries[i].Key, menu.MenuEntries[i].Label) + } +} diff --git a/pkg/client/menu_test.go b/pkg/client/menu_test.go new file mode 100644 index 0000000..35964d8 --- /dev/null +++ b/pkg/client/menu_test.go @@ -0,0 +1,92 @@ +package client + +import ( + "io/ioutil" + "os" + "reflect" + "shell-game-launcher/pkg/config" + "testing" +) + +func TestDisplayMenu(t *testing.T) { + realStdout := os.Stdout + t.Cleanup(func() { os.Stdout = realStdout }) + r, w, _ := os.Pipe() + os.Stdout = w + + // Complete menu, while not logged in + state := State{ + config: &config.Config{ + Menus: map[string]config.Menu{ + "test": config.Menu{ + Banner: "TEST TEST TEST", + MenuEntries: []config.MenuEntry{ + config.MenuEntry{ + Key: "q", + Label: "quit entry", + Action: "quit", + }, + }, + }, + }, + }, + currentMenu: "test", + login: "", + } + want := []byte("\033[2J" + + "TEST TEST TEST\n" + + "\n" + + "Not logged in.\n" + + "\n" + + "q) quit entry\n") + state.displayMenu() + // back to normal state + w.Close() + out, _ := ioutil.ReadAll(r) + if !reflect.DeepEqual(out, want) { + t.Fatalf("menu displayed incorrectly:\nwant:%+v\ngot: %+v", want, out) + } + + // Complete menu, while logged in + r, w, _ = os.Pipe() + os.Stdout = w + + // Complete menu, while not logged in + state = State{ + config: &config.Config{ + Menus: map[string]config.Menu{ + "test": config.Menu{ + Banner: "TEST TEST TEST", + MenuEntries: []config.MenuEntry{ + config.MenuEntry{ + Key: "w", + Label: "wait entry", + Action: "wait", + }, + config.MenuEntry{ + Key: "q", + Label: "quit entry", + Action: "quit", + }, + }, + }, + }, + }, + currentMenu: "test", + login: "test", + } + want = []byte("\033[2J" + + "TEST TEST TEST\n" + + "\n" + + "Logged in as: test\n" + + "\n" + + "w) wait entry\n" + + "q) quit entry\n") + state.displayMenu() + // back to normal state + w.Close() + out, _ = ioutil.ReadAll(r) + if !reflect.DeepEqual(out, want) { + t.Fatalf("menu displayed incorrectly:\nwant:%+v\ngot: %+v", want, out) + } +} diff --git a/pkg/client/state.go b/pkg/client/state.go new file mode 100644 index 0000000..576721c --- /dev/null +++ b/pkg/client/state.go @@ -0,0 +1,23 @@ +package client + +import ( + "shell-game-launcher/pkg/config" +) + +type State struct { + config *config.Config + currentMenu string + login string +} + +func NewState(config *config.Config, login string) *State { + cs := State{ + config: config, + currentMenu: "anonymous", + login: login, + } + if login != "" { + cs.currentMenu = "logged_in" + } + return &cs +} diff --git a/pkg/client/state_test.go b/pkg/client/state_test.go new file mode 100644 index 0000000..917c211 --- /dev/null +++ b/pkg/client/state_test.go @@ -0,0 +1,14 @@ +package client + +import "testing" + +func TestNewState(t *testing.T) { + // Empty login + if s := NewState(nil, ""); s.currentMenu != "anonymous" { + t.Fatal("a new state without login should init to anonymous") + } + // logged_in + if s := NewState(nil, "test"); s.currentMenu != "logged_in" { + t.Fatal("a new state with login should init to logged_in") + } +} 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 |