fix(sdk/go): accept CLI-compatible gateway names (#4767)

* fix(sdk/go): accept CLI-compatible gateway names

Signed-off-by: Sunil Kumar Vadlamani <25136458+sunilteja93@users.noreply.github.com>

* fix(sdk/go): enforce Windows-safe gateway names

Signed-off-by: Sunil Kumar Vadlamani <25136458+sunilteja93@users.noreply.github.com>

---------

Signed-off-by: Sunil Kumar Vadlamani <25136458+sunilteja93@users.noreply.github.com>
Co-authored-by: Sunil Kumar Vadlamani <25136458+sunilteja93@users.noreply.github.com>
This commit is contained in:
Sunil Kumar Vadlamani
2026-10-21 01:45:39 +00:00
committed by GitHub
co-authored by Sunil Kumar Vadlamani
parent 72ab4dd9ef
commit 837fdfdc51
2 changed files with 110 additions and 33 deletions
+32 -27
View File
@@ -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 == '_'
}
+78 -6
View File
@@ -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", "foo<bar", "foo>bar", "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)
}