diff --git a/sdk/go/openshell/v1/gateway/paths.go b/sdk/go/openshell/v1/gateway/paths.go index 0fa9cb009..7107db8bc 100644 --- a/sdk/go/openshell/v1/gateway/paths.go +++ b/sdk/go/openshell/v1/gateway/paths.go @@ -8,7 +8,6 @@ import ( "os" "path/filepath" "strings" - "unicode" ) const ( @@ -91,32 +90,50 @@ func resolveGatewayDir(name string) (string, ConfigSource, error) { return "", "", fmt.Errorf("%w: %q", ErrGatewayNotFound, name) } -// validateGatewayName checks that a gateway name is safe for use as a -// directory component. It rejects: -// - empty names -// - names containing path separators (/ or \) -// - names that are "." or ".." (directory traversal) -// - names containing "." (prevents hidden files and extension confusion) -// - names with characters outside ASCII alphanumerics, dashes, underscores +// validateGatewayName accepts portable, single-component gateway names. +// Host-derived FQDN and IPv4 names may contain dots. Windows device names, +// reserved filename characters, and ambiguous dot components are rejected +// on every platform to keep SDK behavior consistent. func validateGatewayName(name string) error { if name == "" { return fmt.Errorf("%w: name must not be empty", ErrInvalidGatewayName) } - - if strings.ContainsAny(name, "/\\") { + if name == "." || name == ".." { + return fmt.Errorf("%w: invalid path component", ErrInvalidGatewayName) + } + if strings.ContainsAny(name, `/\`) { return fmt.Errorf("%w: name must not contain path separators", ErrInvalidGatewayName) } - - if strings.Contains(name, ".") { - return fmt.Errorf("%w: name must not contain dots", ErrInvalidGatewayName) + if strings.ContainsRune(name, '\x00') { + return fmt.Errorf("%w: name must not contain NUL", ErrInvalidGatewayName) + } + if strings.HasPrefix(name, ".") || strings.HasSuffix(name, ".") || strings.Contains(name, "..") { + return fmt.Errorf("%w: invalid dot placement", ErrInvalidGatewayName) } + // This ASCII-only grammar excludes Windows reserved filename characters + // (including colons in raw IPv6 addresses), spaces, and control characters. for _, r := range name { - if !isValidNameRune(r) { - return fmt.Errorf("%w: name contains invalid character %q", ErrInvalidGatewayName, string(r)) + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || + (r >= '0' && r <= '9') || r == '-' || r == '_' || r == '.' { + continue } + return fmt.Errorf("%w: non-portable character %q", ErrInvalidGatewayName, r) } + // Windows treats device names as reserved even with an extension: + // e.g. CON.txt and COM1.example.com refer to device names. + stem, _, _ := strings.Cut(name, ".") + stem = strings.ToUpper(stem) + switch stem { + case "CON", "PRN", "AUX", "NUL": + return fmt.Errorf("%w: reserved Windows device name", ErrInvalidGatewayName) + } + // Reject COM0/LPT0 conservatively too, for Windows portability. + if len(stem) == 4 && (strings.HasPrefix(stem, "COM") || strings.HasPrefix(stem, "LPT")) && + stem[3] >= '0' && stem[3] <= '9' { + return fmt.Errorf("%w: reserved Windows device name", ErrInvalidGatewayName) + } return nil } @@ -167,15 +184,3 @@ func listGatewayDirs(base string) ([]string, error) { } return names, nil } - -// isValidNameRune returns true if the rune is an ASCII letter, digit, -// dash, or underscore. -func isValidNameRune(r rune) bool { - if r > unicode.MaxASCII { - return false - } - return (r >= 'a' && r <= 'z') || - (r >= 'A' && r <= 'Z') || - (r >= '0' && r <= '9') || - r == '-' || r == '_' -} diff --git a/sdk/go/openshell/v1/gateway/paths_test.go b/sdk/go/openshell/v1/gateway/paths_test.go index 60a91569e..ab22b85f9 100644 --- a/sdk/go/openshell/v1/gateway/paths_test.go +++ b/sdk/go/openshell/v1/gateway/paths_test.go @@ -113,6 +113,15 @@ func TestValidateGatewayName_ValidNames(t *testing.T) { "a", "test-gateway-01", "A_B_C", + "gw.example.com", + "192.0.2.1", + "foo.bar", + "config.json", + "gw-1.example.com", + "COM10", + "LPT10", + "CONSOLE", + "com1x.example.com", } for _, name := range validNames { @@ -147,13 +156,10 @@ func TestValidateGatewayName_PathSeparators(t *testing.T) { } } -func TestValidateGatewayName_Dots(t *testing.T) { +func TestValidateGatewayName_DotComponents(t *testing.T) { cases := []string{ ".", "..", - ".hidden", - "foo.bar", - "config.json", } for _, name := range cases { @@ -220,14 +226,80 @@ func TestValidateGatewayName_SpecialCharacters(t *testing.T) { {"foo#bar", "hash"}, {"café", "non-ASCII"}, {"日本語", "unicode"}, - {"foo bar", "tab (space)"}, + {"foo\tbar", "tab"}, } for _, tc := range cases { t.Run(tc.desc, func(t *testing.T) { err := validateGatewayName(tc.name) - require.Error(t, err) assert.ErrorIs(t, err, ErrInvalidGatewayName) }) } } + +// Windows filename rules apply regardless of the host operating system. +func TestValidateGatewayName_WindowsFilenameRules(t *testing.T) { + invalid := []string{ + "CON", "con.txt", "CON.example.com", + "PRN", "prn.log", "AUX", "aux.json", "NUL", "nul.config", + "COM0", "COM1", "com9.txt", "COM1.example.com", + "LPT0", "LPT1", "lpt9.log", + "gateway.", ".hidden", "foo..bar", "gw.example.com.", + "gateway ", "foo bar", "foo:stream", "C:gateway", + "foo|bar", "foo?bar", "foo*bar", "foobar", "foo\"bar", + "gw\x01bar", "[2001:db8::1]", "2001:db8::1", + } + for _, name := range invalid { + t.Run(name, func(t *testing.T) { + err := validateGatewayName(name) + assert.ErrorIs(t, err, ErrInvalidGatewayName) + }) + } +} + +func TestHostDerivedGatewayNames(t *testing.T) { + tmp := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tmp) + t.Setenv(systemGatewayDirEnv, filepath.Join(tmp, "system")) + + names := []string{"gw.example.com", "192.0.2.1"} + for _, name := range names { + dir := filepath.Join(tmp, "openshell", "gateways", name) + require.NoError(t, os.MkdirAll(dir, 0o755)) + require.NoError(t, os.WriteFile( + filepath.Join(dir, "metadata.json"), + []byte(`{"gateway_endpoint":"https://gw.example.com:443","auth_mode":"none"}`), + 0o600, + )) + + cfg, err := LoadConfig(name) + require.NoError(t, err) + assert.Equal(t, name, cfg.Name) + } + + require.NoError(t, os.WriteFile( + filepath.Join(tmp, "openshell", "active_gateway"), + []byte("gw.example.com"), 0o600, + )) + + active, err := LoadConfig("") + require.NoError(t, err) + assert.Equal(t, "gw.example.com", active.Name) + + gateways, err := ListGateways() + require.NoError(t, err) + got := make(map[string]bool) + for _, gw := range gateways { + got[gw.Name] = gw.Active + } + assert.Equal(t, map[string]bool{ + "gw.example.com": true, + "192.0.2.1": false, + }, got) +} + +func TestValidateGatewayName_NUL(t *testing.T) { + err := validateGatewayName("gw\x00example") + require.Error(t, err) + assert.ErrorIs(t, err, ErrInvalidGatewayName) +}