Skip to content

Commit 785ddc3

Browse files
samuvcursoragent
andcommitted
Tighten Windows named-pipe handling per review
Address aponcedeleonch's review on PR #5201: - Update --socket help to mention named pipes and regen CLI docs. - Promote namedPipePrefix to discovery.NamedPipePrefix as the canonical definition; pkg/api re-aliases it locally so listener and dialer cannot drift. - Make isNamedPipeAddress case-insensitive via strings.EqualFold; the Windows pipe namespace is case-insensitive at the kernel layer, so \\.\Pipe\foo must not silently fall through to AF_UNIX. - Collapse stat-then-remove into a single os.Remove that tolerates fs.ErrNotExist on both POSIX and the Windows AF_UNIX fallback. - Close the listener and remove the socket file when Chmod fails, rather than leaking a bound AF_UNIX listener. - Replace stdruntime.GOOS with a per-platform supportsNamedPipe() helper, dropping the runtime-package alias and its collision with pkg/container/runtime. - Rename socket_other.{go,_test.go} to socket_unix.{go,_test.go} to match the client_unix/client_windows convention used by pkg/container/docker/sdk. - Add TestCreateListener_NamedPipe_Unsupported to round out the listener-side reject coverage on non-Windows builds. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 261fcce commit 785ddc3

7 files changed

Lines changed: 63 additions & 30 deletions

File tree

cmd/thv/app/server.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -192,8 +192,9 @@ func init() {
192192
serveCmd.Flags().IntVar(&port, "port", 8080, "Port to bind the server to")
193193
serveCmd.Flags().BoolVar(&enableDocs, "openapi", false,
194194
"Enable OpenAPI documentation endpoints (/api/openapi.json and /api/doc)")
195-
serveCmd.Flags().StringVar(&socketPath, "socket", "", "UNIX socket path to bind the "+
196-
"server to (overrides host and port if provided)")
195+
serveCmd.Flags().StringVar(&socketPath, "socket", "",
196+
`UNIX socket path or, on Windows, a named pipe (\\.\pipe\<name>) to bind the `+
197+
"server to (overrides host and port if provided)")
197198

198199
// Add experimental MCP server flags
199200
serveCmd.Flags().BoolVar(&enableMCPServer, "experimental-mcp", false,

docs/cli/thv_serve.md

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pkg/api/server.go

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,6 @@ import (
2727
"net/http"
2828
"os"
2929
"path/filepath"
30-
stdruntime "runtime"
3130
"strings"
3231
"time"
3332

@@ -367,16 +366,21 @@ func (b *ServerBuilder) setupDefaultRoutes(r *chi.Mux) {
367366
}
368367
}
369368

370-
// namedPipePrefix is the Windows named-pipe namespace prefix. Both per-platform
371-
// socket files use it, and ListenURL/createListener inspect the address prefix
372-
// to choose between AF_UNIX and named-pipe semantics.
373-
const namedPipePrefix = `\\.\pipe\`
369+
// namedPipePrefix is the Windows named-pipe namespace prefix. The canonical
370+
// definition lives in pkg/server/discovery so the listener and dialer cannot
371+
// drift; pkg/api re-aliases it here so per-platform socket files do not need
372+
// to import discovery directly.
373+
const namedPipePrefix = discovery.NamedPipePrefix
374374

375375
// isNamedPipeAddress reports whether address is a Windows named-pipe path.
376376
// The check is platform-agnostic so callers on non-Windows can fail fast with
377-
// a clear error before reaching the listener code.
377+
// a clear error before reaching the listener code. The comparison is
378+
// case-insensitive because the Windows pipe namespace is case-insensitive at
379+
// the kernel layer; without EqualFold an address like \\.\Pipe\foo would
380+
// silently fall through to AF_UNIX and then fail to bind.
378381
func isNamedPipeAddress(address string) bool {
379-
return strings.HasPrefix(address, namedPipePrefix)
382+
return len(address) >= len(namedPipePrefix) &&
383+
strings.EqualFold(address[:len(namedPipePrefix)], namedPipePrefix)
380384
}
381385

382386
func setupTCPListener(address string) (net.Listener, error) {
@@ -709,7 +713,7 @@ func createListener(address string, isUnixSocket bool) (net.Listener, string, er
709713

710714
addrType := "UNIX socket"
711715
if isNamedPipeAddress(address) {
712-
if stdruntime.GOOS != "windows" {
716+
if !supportsNamedPipe() {
713717
return nil, "", fmt.Errorf("named pipe addresses are only supported on Windows: %s", address)
714718
}
715719
addrType = "Windows named pipe"
Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,21 +6,26 @@
66
package api
77

88
import (
9+
"errors"
910
"fmt"
11+
"io/fs"
1012
"log/slog"
1113
"net"
1214
"os"
1315
"path/filepath"
1416
)
1517

18+
// supportsNamedPipe reports whether the current build target can host a
19+
// Windows named-pipe listener. Used by createListener to reject pipe addresses
20+
// before reaching the per-platform setupUnixSocket implementation.
21+
func supportsNamedPipe() bool { return false }
22+
1623
// setupUnixSocket creates a UNIX domain socket listener at the given path.
1724
// On non-Windows platforms named-pipe addresses are not supported; callers
1825
// guard against that in createListener.
1926
func setupUnixSocket(address string) (net.Listener, error) {
20-
if _, err := os.Stat(address); err == nil {
21-
if err := os.Remove(address); err != nil {
22-
return nil, fmt.Errorf("failed to remove existing socket: %w", err)
23-
}
27+
if err := os.Remove(address); err != nil && !errors.Is(err, fs.ErrNotExist) {
28+
return nil, fmt.Errorf("failed to remove existing socket: %w", err)
2429
}
2530

2631
if err := os.MkdirAll(filepath.Dir(address), 0750); err != nil {
@@ -33,6 +38,11 @@ func setupUnixSocket(address string) (net.Listener, error) {
3338
}
3439

3540
if err := os.Chmod(address, socketPermissions); err != nil {
41+
// Roll back the bound listener and the socket file rather than leaking
42+
// either: the listener owns the AF_UNIX file and net.Listen will not
43+
// rebind the same path while the file exists.
44+
_ = listener.Close()
45+
_ = os.Remove(address)
3646
return nil, fmt.Errorf("failed to set socket permissions: %w", err)
3747
}
3848

@@ -42,7 +52,7 @@ func setupUnixSocket(address string) (net.Listener, error) {
4252
// cleanupUnixSocket removes the socket file at address. Missing files are not
4353
// an error since cleanup may run after a partial startup.
4454
func cleanupUnixSocket(address string) {
45-
if err := os.Remove(address); err != nil && !os.IsNotExist(err) {
55+
if err := os.Remove(address); err != nil && !errors.Is(err, fs.ErrNotExist) {
4656
slog.Warn("failed to remove socket file", "error", err)
4757
}
4858
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"testing"
1010

1111
"github.com/stretchr/testify/assert"
12+
"github.com/stretchr/testify/require"
1213
)
1314

1415
func TestSocketURL_Unix(t *testing.T) {
@@ -25,6 +26,7 @@ func TestIsNamedPipeAddress(t *testing.T) {
2526
}{
2627
{"plain socket", "/tmp/thv.sock", false},
2728
{"named pipe", `\\.\pipe\thv-api`, true},
29+
{"named pipe mixed case", `\\.\Pipe\thv-api`, true},
2830
{"empty", "", false},
2931
}
3032
for _, tt := range tests {
@@ -34,3 +36,13 @@ func TestIsNamedPipeAddress(t *testing.T) {
3436
})
3537
}
3638
}
39+
40+
// TestCreateListener_NamedPipe_Unsupported asserts that createListener rejects
41+
// pipe addresses on non-Windows up front, mirroring the dialer-side guard
42+
// covered by TestCheckHealth_NamedPipe_Unsupported_OnNonWindows.
43+
func TestCreateListener_NamedPipe_Unsupported(t *testing.T) {
44+
t.Parallel()
45+
_, _, err := createListener(`\\.\pipe\thv-api`, true)
46+
require.Error(t, err)
47+
assert.Contains(t, err.Error(), "only supported on Windows")
48+
}

pkg/api/socket_windows.go

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,13 @@
66
package api
77

88
import (
9+
"errors"
910
"fmt"
11+
"io/fs"
1012
"log/slog"
1113
"net"
1214
"os"
1315
"path/filepath"
14-
"strings"
1516

1617
"github.com/Microsoft/go-winio"
1718
)
@@ -21,6 +22,11 @@ import (
2122
// (Docker, containerd, Podman) and is well above any single HTTP header chunk.
2223
const namedPipeBufferSize = 64 * 1024
2324

25+
// supportsNamedPipe reports whether the current build target can host a
26+
// Windows named-pipe listener. Used by createListener to choose between the
27+
// pipe and AF_UNIX paths without dragging the runtime package into server.go.
28+
func supportsNamedPipe() bool { return true }
29+
2430
// setupUnixSocket creates either a Windows named-pipe listener (when address
2531
// has the \\.\pipe\ prefix) or an AF_UNIX listener at a filesystem path.
2632
//
@@ -33,7 +39,7 @@ const namedPipeBufferSize = 64 * 1024
3339
// AF_UNIX is supported on Windows 10 1803+. The chmod step is dropped on this
3440
// path because POSIX file modes do not apply on Windows.
3541
func setupUnixSocket(address string) (net.Listener, error) {
36-
if strings.HasPrefix(address, namedPipePrefix) {
42+
if isNamedPipeAddress(address) {
3743
// MessageMode is left at false (byte stream) explicitly because HTTP
3844
// requires byte-oriented framing.
3945
listener, err := winio.ListenPipe(address, &winio.PipeConfig{
@@ -47,10 +53,8 @@ func setupUnixSocket(address string) (net.Listener, error) {
4753
return listener, nil
4854
}
4955

50-
if _, err := os.Stat(address); err == nil {
51-
if err := os.Remove(address); err != nil {
52-
return nil, fmt.Errorf("failed to remove existing socket: %w", err)
53-
}
56+
if err := os.Remove(address); err != nil && !errors.Is(err, fs.ErrNotExist) {
57+
return nil, fmt.Errorf("failed to remove existing socket: %w", err)
5458
}
5559

5660
if err := os.MkdirAll(filepath.Dir(address), 0750); err != nil {
@@ -68,10 +72,10 @@ func setupUnixSocket(address string) (net.Listener, error) {
6872
// cleanupUnixSocket removes the AF_UNIX socket file at address, or no-ops for
6973
// named pipes (the pipe is destroyed when the listener closes).
7074
func cleanupUnixSocket(address string) {
71-
if strings.HasPrefix(address, namedPipePrefix) {
75+
if isNamedPipeAddress(address) {
7276
return
7377
}
74-
if err := os.Remove(address); err != nil && !os.IsNotExist(err) {
78+
if err := os.Remove(address); err != nil && !errors.Is(err, fs.ErrNotExist) {
7579
slog.Warn("failed to remove socket file", "error", err)
7680
}
7781
}
@@ -80,8 +84,8 @@ func cleanupUnixSocket(address string) {
8084
// the discovery file. Named pipes are emitted as npipe://<name> where <name>
8185
// is everything after the \\.\pipe\ prefix.
8286
func socketURL(address string) string {
83-
if strings.HasPrefix(address, namedPipePrefix) {
84-
return "npipe://" + strings.TrimPrefix(address, namedPipePrefix)
87+
if isNamedPipeAddress(address) {
88+
return "npipe://" + address[len(namedPipePrefix):]
8589
}
8690
return "unix://" + address
8791
}

pkg/server/discovery/health.go

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,11 @@ const (
2323
NonceHeader = "X-Toolhive-Nonce"
2424
)
2525

26-
// namedPipePrefix is the Windows named-pipe namespace prefix used to
27-
// reconstruct addresses for winio.DialPipeContext.
28-
const namedPipePrefix = `\\.\pipe\`
26+
// NamedPipePrefix is the Windows named-pipe namespace prefix. The discovery
27+
// dialer uses it to reconstruct addresses for winio.DialPipeContext, and the
28+
// API listener (pkg/api) imports it as the canonical definition so the two
29+
// sides cannot drift.
30+
const NamedPipePrefix = `\\.\pipe\`
2931

3032
// CheckHealth verifies that a server at the given URL is healthy and optionally
3133
// matches the expected nonce. It supports http://, unix://, and npipe:// URL
@@ -184,5 +186,5 @@ func ParseNamedPipeURL(rawURL string) (string, error) {
184186
if strings.Contains(name, "..") {
185187
return "", fmt.Errorf("named pipe name must not contain '..': %s", name)
186188
}
187-
return namedPipePrefix + name, nil
189+
return NamedPipePrefix + name, nil
188190
}

0 commit comments

Comments
 (0)