From 3dd04408323bcf9203db344ea3124bfae952df73 Mon Sep 17 00:00:00 2001 From: IliaDenisov Date: Fri, 6 Feb 2026 19:31:35 +0300 Subject: [PATCH] feat: validate user input for entity names --- internal/controller/controller_helper.go | 20 --- internal/controller/fleet.go | 5 +- internal/controller/planet.go | 3 +- internal/controller/science.go | 3 +- internal/controller/ship_class.go | 3 +- internal/controller/ship_group.go | 3 +- internal/game/cmd_science_test.go | 2 +- internal/number/number.go | 12 +- internal/number/number_test.go | 7 + internal/util/string.go | 64 +++++++ internal/util/string_test.go | 210 +++++++++++++++++++++++ 11 files changed, 304 insertions(+), 28 deletions(-) delete mode 100644 internal/controller/controller_helper.go create mode 100644 internal/util/string.go create mode 100644 internal/util/string_test.go diff --git a/internal/controller/controller_helper.go b/internal/controller/controller_helper.go deleted file mode 100644 index f4caaa9..0000000 --- a/internal/controller/controller_helper.go +++ /dev/null @@ -1,20 +0,0 @@ -package controller - -import "strings" - -// validateTypeName always return v without leading and trailing spaces -func validateTypeName(v string) (string, bool) { - s := strings.TrimSpace(v) - if len(s) > 0 { - return s, true - } - // TODO: special symbols AND include error check in all user-input test - return s, false -} - -func maxUint(a, b uint) uint { - if b > a { - return b - } - return a -} diff --git a/internal/controller/fleet.go b/internal/controller/fleet.go index 6ac1ad4..80a9af7 100644 --- a/internal/controller/fleet.go +++ b/internal/controller/fleet.go @@ -9,6 +9,7 @@ import ( "github.com/google/uuid" e "github.com/iliadenisov/galaxy/internal/error" "github.com/iliadenisov/galaxy/internal/model/game" + "github.com/iliadenisov/galaxy/internal/util" ) var fleetStateNil = game.ShipGroupState("-") @@ -110,7 +111,7 @@ func (c *Controller) JoinShipGroupToFleet(raceName, fleetName string, group, cou func (c *Cache) JoinShipGroupToFleet(ri int, fleetName string, groupIndex, quantity uint) (err error) { c.validateRaceIndex(ri) - name, ok := validateTypeName(fleetName) + name, ok := util.ValidateTypeName(fleetName) if !ok { return e.NewEntityTypeNameValidationError("%q", name) } @@ -211,7 +212,7 @@ func (c *Cache) JoinFleets(ri int, fleetSourceName, fleetTargetName string) (err func (c *Cache) createFleet(ri int, name string) (int, error) { c.validateRaceIndex(ri) - n, ok := validateTypeName(name) + n, ok := util.ValidateTypeName(name) if !ok { return 0, e.NewEntityTypeNameValidationError("%q", n) } diff --git a/internal/controller/planet.go b/internal/controller/planet.go index 5151e81..2220a42 100644 --- a/internal/controller/planet.go +++ b/internal/controller/planet.go @@ -9,6 +9,7 @@ import ( "github.com/google/uuid" e "github.com/iliadenisov/galaxy/internal/error" "github.com/iliadenisov/galaxy/internal/model/game" + "github.com/iliadenisov/galaxy/internal/util" ) func (c *Controller) RenamePlanet(raceName string, planetNumber int, typeName string) error { @@ -20,7 +21,7 @@ func (c *Controller) RenamePlanet(raceName string, planetNumber int, typeName st } func (c *Cache) RenamePlanet(ri int, number int, name string) error { - n, ok := validateTypeName(name) + n, ok := util.ValidateTypeName(name) if !ok { return e.NewEntityTypeNameValidationError("%q", n) } diff --git a/internal/controller/science.go b/internal/controller/science.go index bf4be47..7f6bb55 100644 --- a/internal/controller/science.go +++ b/internal/controller/science.go @@ -7,6 +7,7 @@ import ( "github.com/google/uuid" e "github.com/iliadenisov/galaxy/internal/error" "github.com/iliadenisov/galaxy/internal/model/game" + "github.com/iliadenisov/galaxy/internal/util" ) func (c *Controller) CreateScience(raceName, typeName string, drive, weapons, shields, cargo float64) error { @@ -19,7 +20,7 @@ func (c *Controller) CreateScience(raceName, typeName string, drive, weapons, sh func (c *Cache) CreateScience(ri int, name string, drive, weapons, shileds, cargo float64) error { c.validateRaceIndex(ri) - n, ok := validateTypeName(name) + n, ok := util.ValidateTypeName(name) if !ok { return e.NewEntityTypeNameValidationError("%q", n) } diff --git a/internal/controller/ship_class.go b/internal/controller/ship_class.go index d7914c5..2a8d1ed 100644 --- a/internal/controller/ship_class.go +++ b/internal/controller/ship_class.go @@ -8,6 +8,7 @@ import ( "github.com/google/uuid" e "github.com/iliadenisov/galaxy/internal/error" "github.com/iliadenisov/galaxy/internal/model/game" + "github.com/iliadenisov/galaxy/internal/util" ) func (c *Controller) CreateShipType(raceName, typeName string, drive float64, ammo int, weapons, shileds, cargo float64) error { @@ -23,7 +24,7 @@ func (c *Cache) CreateShipType(ri int, typeName string, drive float64, ammo int, if err := checkShipTypeValues(drive, ammo, weapons, shileds, cargo); err != nil { return err } - n, ok := validateTypeName(typeName) + n, ok := util.ValidateTypeName(typeName) if !ok { return e.NewEntityTypeNameValidationError("%q", n) } diff --git a/internal/controller/ship_group.go b/internal/controller/ship_group.go index 0c69835..f9aa18a 100644 --- a/internal/controller/ship_group.go +++ b/internal/controller/ship_group.go @@ -9,6 +9,7 @@ import ( "github.com/google/uuid" e "github.com/iliadenisov/galaxy/internal/error" "github.com/iliadenisov/galaxy/internal/model/game" + "github.com/iliadenisov/galaxy/internal/number" ) func (c *Cache) CreateShips(ri int, shipTypeName string, planetNumber uint, quantity int) error { @@ -154,7 +155,7 @@ func (c *Cache) JoinEqualGroups(ri int) { for i := 0; i < len(raceGroups)-1; i++ { for j := len(raceGroups) - 1; j > i; j-- { if raceGroups[i].Equal(raceGroups[j]) { - raceGroups[i].Index = maxUint(raceGroups[i].Index, raceGroups[j].Index) + raceGroups[i].Index = number.Max(raceGroups[i].Index, raceGroups[j].Index) raceGroups[i].Number += raceGroups[j].Number raceGroups = append(raceGroups[:j], raceGroups[j+1:]...) } diff --git a/internal/game/cmd_science_test.go b/internal/game/cmd_science_test.go index f843472..eba707c 100644 --- a/internal/game/cmd_science_test.go +++ b/internal/game/cmd_science_test.go @@ -23,7 +23,7 @@ func TestCreateScience(t *testing.T) { func TestCreateScienceValidation(t *testing.T) { race := "race_01" - typeName := "First Step" + typeName := "First_Step" type tc struct { name string d, w, s, c float64 diff --git a/internal/number/number.go b/internal/number/number.go index 3a8bf6d..773ae89 100644 --- a/internal/number/number.go +++ b/internal/number/number.go @@ -1,6 +1,9 @@ package number -import "math" +import ( + "cmp" + "math" +) func Fixed3(num float64) float64 { return fixed(num, 3) @@ -18,3 +21,10 @@ func fixed(num float64, precision int) float64 { func round(num float64) int { return int(num + math.Copysign(0.5, num)) } + +func Max[T cmp.Ordered](x, y T) T { + if cmp.Compare(x, y) == 1 { + return x + } + return y +} diff --git a/internal/number/number_test.go b/internal/number/number_test.go index f14a6c2..40b2fa0 100644 --- a/internal/number/number_test.go +++ b/internal/number/number_test.go @@ -31,3 +31,10 @@ func TestFixed(t *testing.T) { }) } } + +func TestMax(t *testing.T) { + assert.Equal(t, 10., Max(9., 10.)) + assert.Equal(t, 11., Max(11., 10.)) + assert.Equal(t, 0, Max(-1, 0)) + assert.Equal(t, 1, Max(1, 0)) +} diff --git a/internal/util/string.go b/internal/util/string.go new file mode 100644 index 0000000..388215c --- /dev/null +++ b/internal/util/string.go @@ -0,0 +1,64 @@ +package util + +import ( + "strings" + "unicode" +) + +// Allowed special characters +var allowedSpecialChars = map[rune]bool{ + '@': true, + '^': true, + '~': true, + '-': true, + '_': true, +} + +func ValidateTypeName(input string) (string, bool) { + // Trim leading and trailing spaces + trimmed := strings.TrimSpace(input) + + // If the string is empty after trimming, return false + if len(trimmed) == 0 { + return "", false + } + + runes := []rune(trimmed) + + if len(runes) > 30 { + return "", false + } + + // Dash cannot be at the beginning or end + if runes[0] == '-' || runes[len(runes)-1] == '-' { + return "", false + } + + for _, r := range runes { + // Check if the character is a whitespace, which is not allowed + if unicode.IsSpace(r) { + return "", false + } + + // Letters (including any alphabet) and digits are allowed + if unicode.IsLetter(r) || unicode.IsDigit(r) { + continue + } + + // Combining marks (accents) are allowed + if unicode.IsMark(r) { + continue + } + + // Check for allowed special characters + if allowedSpecialChars[r] { + continue + } + + // If any other character is encountered, return false + return "", false + } + + // Return the trimmed string and true if all conditions are met + return trimmed, true +} diff --git a/internal/util/string_test.go b/internal/util/string_test.go new file mode 100644 index 0000000..8b99475 --- /dev/null +++ b/internal/util/string_test.go @@ -0,0 +1,210 @@ +package util_test + +import ( + "testing" + "unicode/utf8" + + "github.com/iliadenisov/galaxy/internal/util" + "github.com/stretchr/testify/assert" +) + +func TestValidateString(t *testing.T) { + tests := []struct { + name string + input string + expected string + ok bool + }{ + // Basic cases + { + name: "Valid string with Latin characters and digits", + input: "Hello_World-123", + expected: "Hello_World-123", + ok: true, + }, + { + name: "Valid string with Cyrillic characters", + input: "Привет_мир-42", + expected: "Привет_мир-42", + ok: true, + }, + { + name: "Valid Greek alphabet string", + input: "Αλφα_Βητα-2024", + expected: "Αλφα_Βητα-2024", + ok: true, + }, + { + name: "Valid Arabic alphabet string", + input: "مرحبا_العالم-7", + expected: "مرحبا_العالم-7", + ok: true, + }, + { + name: "Valid Japanese Katakana string", + input: "テスト_ケース-1", + expected: "テスト_ケース-1", + ok: true, + }, + { + name: "Valid Chinese characters", + input: "你好_世界-123", // "Hello World" in Chinese + expected: "你好_世界-123", + ok: true, + }, + { + name: "Valid Hindi characters", + input: "नमस्ते_दुनिया-456", // "Hello World" in Hindi + expected: "नमस्ते_दुनिया-456", + ok: true, + }, + { + name: "Valid Thai characters", + input: "สวัสดี_โลก-789", // "Hello World" in Thai + expected: "สวัสดี_โลก-789", + ok: true, + }, + { + name: "Valid Korean characters", + input: "안녕하세요_세계-101", // "Hello World" in Korean + expected: "안녕하세요_세계-101", + ok: true, + }, + { + name: "Valid Hebrew characters", + input: "שלום_עולם-202", // "Hello World" in Hebrew + expected: "שלום_עולם-202", + ok: true, + }, + // Special characters test cases + { + name: "Valid special character @", + input: "Test@Name", + expected: "Test@Name", + ok: true, + }, + { + name: "Valid special character ^", + input: "Test^Name", + expected: "Test^Name", + ok: true, + }, + { + name: "Valid special character ~", + input: "Test~Name", + expected: "Test~Name", + ok: true, + }, + // Edge cases + { + name: "Spaces are trimmed from both ends", + input: " Test123_Name ", + expected: "Test123_Name", + ok: true, + }, + { + name: "Spaces in the middle are not allowed", + input: "Test 123", + expected: "", + ok: false, + }, + { + name: "Tab character in the middle is not allowed", + input: "Test\tName", + expected: "", + ok: false, + }, + { + name: "Newline character is not allowed", + input: "Test\nName", + expected: "", + ok: false, + }, + { + name: "Dash at the beginning after TrimSpace is not allowed", + input: " -Test123", + expected: "", + ok: false, + }, + { + name: "Dash at the end after TrimSpace is not allowed", + input: "Test123- ", + expected: "", + ok: false, + }, + { + name: "Emoji is not allowed", + input: "Test🙂Name", + expected: "", + ok: false, + }, + { + name: "String containing only spaces", + input: " ", + expected: "", + ok: false, + }, + { + name: "Empty string", + input: "", + expected: "", + ok: false, + }, + { + name: "Too long string", + input: "ValidatedStringHasTooManyCharacters", + expected: "", + ok: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, ok := util.ValidateTypeName(tt.input) + assert.Equal(t, tt.ok, ok) + assert.Equal(t, tt.expected, result) + }) + } +} + +// Fuzz test for ValidateString function +func FuzzValidateString(f *testing.F) { + // Adding a few basic strings to start the fuzz test + f.Add("Hello_World-123") + f.Add("Test@Name") + f.Add("Привет_мир-42") + f.Add("αβγ@~") + f.Add("مرحبا_العالم-7") + + // Fuzz function + f.Fuzz(func(t *testing.T, input string) { + // Call the function and check if the result matches expectations + result, ok := util.ValidateTypeName(input) + + // Check if the string is non-empty and valid UTF-8 + if len(input) > 0 { + if !utf8.ValidString(input) { + t.Errorf("Error: string is not a valid UTF-8 string: %s", input) + } + } + + // If the string is empty, ok should be false + if len(result) == 0 { + if ok { + t.Errorf("Expected false for invalid string, but got true: %s", input) + } + } else { + // If the result is not empty, ok should be true + if !ok { + t.Errorf("Expected true for valid string, but got false: %s", input) + } + } + + // Additional check: if input has spaces at the beginning or end, it should fail + if input[0] == ' ' || input[len(input)-1] == ' ' { + if ok { + t.Errorf("Error: string contains spaces at the beginning or end: %s", input) + } + } + }) +}