Skip to content

Commit 837fdfd

Browse files
authored
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>
1 parent 72ab4dd commit 837fdfd

2 files changed

Lines changed: 110 additions & 33 deletions

File tree

‎sdk/go/openshell/v1/gateway/paths.go‎

Lines changed: 32 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import (
88
"os"
99
"path/filepath"
1010
"strings"
11-
"unicode"
1211
)
1312

1413
const (
@@ -91,32 +90,50 @@ func resolveGatewayDir(name string) (string, ConfigSource, error) {
9190
return "", "", fmt.Errorf("%w: %q", ErrGatewayNotFound, name)
9291
}
9392

94-
// validateGatewayName checks that a gateway name is safe for use as a
95-
// directory component. It rejects:
96-
// - empty names
97-
// - names containing path separators (/ or \)
98-
// - names that are "." or ".." (directory traversal)
99-
// - names containing "." (prevents hidden files and extension confusion)
100-
// - names with characters outside ASCII alphanumerics, dashes, underscores
93+
// validateGatewayName accepts portable, single-component gateway names.
94+
// Host-derived FQDN and IPv4 names may contain dots. Windows device names,
95+
// reserved filename characters, and ambiguous dot components are rejected
96+
// on every platform to keep SDK behavior consistent.
10197
func validateGatewayName(name string) error {
10298
if name == "" {
10399
return fmt.Errorf("%w: name must not be empty", ErrInvalidGatewayName)
104100
}
105-
106-
if strings.ContainsAny(name, "/\\") {
101+
if name == "." || name == ".." {
102+
return fmt.Errorf("%w: invalid path component", ErrInvalidGatewayName)
103+
}
104+
if strings.ContainsAny(name, `/\`) {
107105
return fmt.Errorf("%w: name must not contain path separators", ErrInvalidGatewayName)
108106
}
109-
110-
if strings.Contains(name, ".") {
111-
return fmt.Errorf("%w: name must not contain dots", ErrInvalidGatewayName)
107+
if strings.ContainsRune(name, '\x00') {
108+
return fmt.Errorf("%w: name must not contain NUL", ErrInvalidGatewayName)
109+
}
110+
if strings.HasPrefix(name, ".") || strings.HasSuffix(name, ".") || strings.Contains(name, "..") {
111+
return fmt.Errorf("%w: invalid dot placement", ErrInvalidGatewayName)
112112
}
113113

114+
// This ASCII-only grammar excludes Windows reserved filename characters
115+
// (including colons in raw IPv6 addresses), spaces, and control characters.
114116
for _, r := range name {
115-
if !isValidNameRune(r) {
116-
return fmt.Errorf("%w: name contains invalid character %q", ErrInvalidGatewayName, string(r))
117+
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') ||
118+
(r >= '0' && r <= '9') || r == '-' || r == '_' || r == '.' {
119+
continue
117120
}
121+
return fmt.Errorf("%w: non-portable character %q", ErrInvalidGatewayName, r)
118122
}
119123

124+
// Windows treats device names as reserved even with an extension:
125+
// e.g. CON.txt and COM1.example.com refer to device names.
126+
stem, _, _ := strings.Cut(name, ".")
127+
stem = strings.ToUpper(stem)
128+
switch stem {
129+
case "CON", "PRN", "AUX", "NUL":
130+
return fmt.Errorf("%w: reserved Windows device name", ErrInvalidGatewayName)
131+
}
132+
// Reject COM0/LPT0 conservatively too, for Windows portability.
133+
if len(stem) == 4 && (strings.HasPrefix(stem, "COM") || strings.HasPrefix(stem, "LPT")) &&
134+
stem[3] >= '0' && stem[3] <= '9' {
135+
return fmt.Errorf("%w: reserved Windows device name", ErrInvalidGatewayName)
136+
}
120137
return nil
121138
}
122139

@@ -167,15 +184,3 @@ func listGatewayDirs(base string) ([]string, error) {
167184
}
168185
return names, nil
169186
}
170-
171-
// isValidNameRune returns true if the rune is an ASCII letter, digit,
172-
// dash, or underscore.
173-
func isValidNameRune(r rune) bool {
174-
if r > unicode.MaxASCII {
175-
return false
176-
}
177-
return (r >= 'a' && r <= 'z') ||
178-
(r >= 'A' && r <= 'Z') ||
179-
(r >= '0' && r <= '9') ||
180-
r == '-' || r == '_'
181-
}

‎sdk/go/openshell/v1/gateway/paths_test.go‎

Lines changed: 78 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,15 @@ func TestValidateGatewayName_ValidNames(t *testing.T) {
113113
"a",
114114
"test-gateway-01",
115115
"A_B_C",
116+
"gw.example.com",
117+
"192.0.2.1",
118+
"foo.bar",
119+
"config.json",
120+
"gw-1.example.com",
121+
"COM10",
122+
"LPT10",
123+
"CONSOLE",
124+
"com1x.example.com",
116125
}
117126

118127
for _, name := range validNames {
@@ -147,13 +156,10 @@ func TestValidateGatewayName_PathSeparators(t *testing.T) {
147156
}
148157
}
149158

150-
func TestValidateGatewayName_Dots(t *testing.T) {
159+
func TestValidateGatewayName_DotComponents(t *testing.T) {
151160
cases := []string{
152161
".",
153162
"..",
154-
".hidden",
155-
"foo.bar",
156-
"config.json",
157163
}
158164

159165
for _, name := range cases {
@@ -220,14 +226,80 @@ func TestValidateGatewayName_SpecialCharacters(t *testing.T) {
220226
{"foo#bar", "hash"},
221227
{"café", "non-ASCII"},
222228
{"日本語", "unicode"},
223-
{"foo bar", "tab (space)"},
229+
{"foo\tbar", "tab"},
224230
}
225231

226232
for _, tc := range cases {
227233
t.Run(tc.desc, func(t *testing.T) {
228234
err := validateGatewayName(tc.name)
229-
require.Error(t, err)
230235
assert.ErrorIs(t, err, ErrInvalidGatewayName)
231236
})
232237
}
233238
}
239+
240+
// Windows filename rules apply regardless of the host operating system.
241+
func TestValidateGatewayName_WindowsFilenameRules(t *testing.T) {
242+
invalid := []string{
243+
"CON", "con.txt", "CON.example.com",
244+
"PRN", "prn.log", "AUX", "aux.json", "NUL", "nul.config",
245+
"COM0", "COM1", "com9.txt", "COM1.example.com",
246+
"LPT0", "LPT1", "lpt9.log",
247+
"gateway.", ".hidden", "foo..bar", "gw.example.com.",
248+
"gateway ", "foo bar", "foo:stream", "C:gateway",
249+
"foo|bar", "foo?bar", "foo*bar", "foo<bar", "foo>bar", "foo\"bar",
250+
"gw\x01bar", "[2001:db8::1]", "2001:db8::1",
251+
}
252+
for _, name := range invalid {
253+
t.Run(name, func(t *testing.T) {
254+
err := validateGatewayName(name)
255+
assert.ErrorIs(t, err, ErrInvalidGatewayName)
256+
})
257+
}
258+
}
259+
260+
func TestHostDerivedGatewayNames(t *testing.T) {
261+
tmp := t.TempDir()
262+
t.Setenv("XDG_CONFIG_HOME", tmp)
263+
t.Setenv(systemGatewayDirEnv, filepath.Join(tmp, "system"))
264+
265+
names := []string{"gw.example.com", "192.0.2.1"}
266+
for _, name := range names {
267+
dir := filepath.Join(tmp, "openshell", "gateways", name)
268+
require.NoError(t, os.MkdirAll(dir, 0o755))
269+
require.NoError(t, os.WriteFile(
270+
filepath.Join(dir, "metadata.json"),
271+
[]byte(`{"gateway_endpoint":"https://gw.example.com:443","auth_mode":"none"}`),
272+
0o600,
273+
))
274+
275+
cfg, err := LoadConfig(name)
276+
require.NoError(t, err)
277+
assert.Equal(t, name, cfg.Name)
278+
}
279+
280+
require.NoError(t, os.WriteFile(
281+
filepath.Join(tmp, "openshell", "active_gateway"),
282+
[]byte("gw.example.com"), 0o600,
283+
))
284+
285+
active, err := LoadConfig("")
286+
require.NoError(t, err)
287+
assert.Equal(t, "gw.example.com", active.Name)
288+
289+
gateways, err := ListGateways()
290+
require.NoError(t, err)
291+
got := make(map[string]bool)
292+
for _, gw := range gateways {
293+
got[gw.Name] = gw.Active
294+
}
295+
assert.Equal(t, map[string]bool{
296+
"gw.example.com": true,
297+
"192.0.2.1": false,
298+
}, got)
299+
}
300+
301+
func TestValidateGatewayName_NUL(t *testing.T) {
302+
err := validateGatewayName("gw\x00example")
303+
require.Error(t, err)
304+
assert.ErrorIs(t, err, ErrInvalidGatewayName)
305+
}

0 commit comments

Comments
 (0)