From d13abfd3e246bf86aa534db376fb65c459361004 Mon Sep 17 00:00:00 2001 From: Andrew Branch Date: Wed, 14 Oct 2026 22:32:31 +0000 Subject: [PATCH] [api] Fix named pipe / UDS creation (#64818) (cherry picked from commit 257e87406c6a8be277cb8cd5ef8d7b458a650390) --- packages/typescript/src/api/syncChannel.ts | 7 +- tsc/cmd/tsc/api.go | 2 +- tsc/internal/api/server.go | 4 +- tsc/internal/api/transport.go | 32 +++++- tsc/internal/api/transport_unix.go | 53 +++++++-- tsc/internal/api/transport_unix_test.go | 103 ++++++++++++++++++ tsc/internal/api/transport_windows.go | 17 ++- tsc/internal/api/transport_windows_test.go | 76 +++++++++++++ .../lsp/lsproto/_generate/generate.mts | 2 +- tsc/internal/lsp/lsproto/lsp_generated.go | 2 +- tsc/internal/lsp/server.go | 17 ++- 11 files changed, 285 insertions(+), 30 deletions(-) create mode 100644 tsc/internal/api/transport_unix_test.go create mode 100644 tsc/internal/api/transport_windows_test.go diff --git a/packages/typescript/src/api/syncChannel.ts b/packages/typescript/src/api/syncChannel.ts index 122203c85e38e..6f859bd4087ee 100644 --- a/packages/typescript/src/api/syncChannel.ts +++ b/packages/typescript/src/api/syncChannel.ts @@ -14,6 +14,7 @@ import { type ChildProcess, spawn, } from "node:child_process"; +import { randomBytes } from "node:crypto"; import { closeSync, openSync, @@ -86,6 +87,10 @@ process.on("exit", () => { liveChildren.clear(); }); +export function createPipePath(): string { + return `\\\\.\\pipe\\tsgo-sync-${process.pid}-${randomBytes(16).toString("hex")}`; +} + /** * SyncRpcChannel – drop-in replacement for the native libsyncrpc class. * @@ -147,7 +152,7 @@ export class SyncRpcChannel { // we create a Windows named pipe path, pass it to the child // via --pipe, and open it with fs.openSync which returns a // real C-runtime fd backed by a proper HANDLE. - const pipePath = `\\\\.\\pipe\\tsgo-sync-${process.pid}-${Date.now()}`; + const pipePath = createPipePath(); this.child = spawn(exe, [...args, "--pipe", pipePath], { stdio: ["ignore", "ignore", "inherit"], }); diff --git a/tsc/cmd/tsc/api.go b/tsc/cmd/tsc/api.go index a46032084e5ae..221657928c155 100644 --- a/tsc/cmd/tsc/api.go +++ b/tsc/cmd/tsc/api.go @@ -17,7 +17,7 @@ import ( func runAPI(args []string) int { flag := flag.NewFlagSet("api", flag.ContinueOnError) cwd := flag.String("cwd", core.Must(os.Getwd()), "current working directory") - pipePath := flag.String("pipe", "", "use named pipe or Unix domain socket for communication instead of stdio") + pipePath := flag.String("pipe", "", "use a named pipe (Windows) or create a private Unix socket at /socket, where must not exist") callbacks := flag.String("callbacks", "", "comma-separated list of FS callbacks to enable (readFile,fileExists,directoryExists,getAccessibleEntries,realpath)") async := flag.Bool("async", false, "use JSON-RPC protocol instead of MessagePack (for async API)") timing := flag.Bool("timing", false, "collect per-request server processing time, folded into the client's timing snapshot") diff --git a/tsc/internal/api/server.go b/tsc/internal/api/server.go index 107d5639d1fe5..817ddebffa2e7 100644 --- a/tsc/internal/api/server.go +++ b/tsc/internal/api/server.go @@ -18,8 +18,8 @@ type StdioServerOptions struct { Err io.Writer Cwd string DefaultLibraryPath string - // PipePath, if set, listens on a named pipe (Windows) or Unix domain - // socket instead of using In/Out for communication. + // PipePath, if set, is the named pipe path on Windows. On Unix, it must + // not exist and is created as a private directory containing "socket". PipePath string // Callbacks specifies which filesystem operations should be delegated // to the client (e.g., "readFile", "fileExists"). Empty means no callbacks. diff --git a/tsc/internal/api/transport.go b/tsc/internal/api/transport.go index 4eb3d26ad3176..9e14c83ece4cc 100644 --- a/tsc/internal/api/transport.go +++ b/tsc/internal/api/transport.go @@ -1,6 +1,7 @@ package api import ( + "errors" "io" "net" ) @@ -16,16 +17,33 @@ type Transport interface { // PipeTransport accepts connections on a Unix domain socket or Windows named pipe. type PipeTransport struct { listener net.Listener + cleanup func() error } -// NewPipeTransport creates a new transport listening on the given path. -// On Unix, this creates a Unix domain socket. On Windows, this creates a named pipe. +// NewPipeTransport creates a new pipe transport. +// On Unix, path must not exist and is created as a private directory containing "socket". +// On Windows, path is the named pipe path. func NewPipeTransport(path string) (*PipeTransport, error) { - listener, err := newPipeListener(path) + listener, cleanup, err := newPipeListener(path) if err != nil { return nil, err } - return &PipeTransport{listener: listener}, nil + return &PipeTransport{ + listener: listener, + cleanup: cleanup, + }, nil +} + +// NewPrivatePipeTransport creates a transport at a private, generated path. +func NewPrivatePipeTransport(name string) (*PipeTransport, error) { + listener, cleanup, err := newPrivatePipeListener(name) + if err != nil { + return nil, err + } + return &PipeTransport{ + listener: listener, + cleanup: cleanup, + }, nil } // Accept implements Transport. @@ -35,7 +53,11 @@ func (t *PipeTransport) Accept() (io.ReadWriteCloser, error) { // Close implements Transport. func (t *PipeTransport) Close() error { - return t.listener.Close() + closeErr := t.listener.Close() + if t.cleanup == nil || errors.Is(closeErr, net.ErrClosed) { + return closeErr + } + return errors.Join(closeErr, t.cleanup()) } // Path returns the path of the pipe/socket. diff --git a/tsc/internal/api/transport_unix.go b/tsc/internal/api/transport_unix.go index 667ed7c0d0651..60b57da153f6e 100644 --- a/tsc/internal/api/transport_unix.go +++ b/tsc/internal/api/transport_unix.go @@ -3,20 +3,59 @@ package api import ( + "errors" + "fmt" + "io/fs" "net" "os" - "path" + "path/filepath" ) -// newPipeListener creates a Unix domain socket listener. -func newPipeListener(path string) (net.Listener, error) { - // Remove any existing socket file - _ = os.Remove(path) //nolint:forbidigo - return net.Listen("unix", path) +func newPipeListener(dir string) (net.Listener, func() error, error) { + if err := os.Mkdir(dir, 0o700); err != nil { //nolint:forbidigo + return nil, nil, fmt.Errorf("create private socket directory: %w", err) + } + return listenInPrivatePipeDirectory(dir) +} + +func newPrivatePipeListener(_ string) (net.Listener, func() error, error) { + dir, err := os.MkdirTemp("", "tsgo-api-") //nolint:forbidigo + if err != nil { + return nil, nil, fmt.Errorf("create private socket directory: %w", err) + } + return listenInPrivatePipeDirectory(dir) +} + +func listenInPrivatePipeDirectory(dir string) (net.Listener, func() error, error) { + socketPath := filepath.Join(dir, "socket") //nolint:forbidigo + cleanup := func() error { + socketErr := os.Remove(socketPath) //nolint:forbidigo + if errors.Is(socketErr, fs.ErrNotExist) { + socketErr = nil + } + dirErr := os.Remove(dir) //nolint:forbidigo + if errors.Is(dirErr, fs.ErrNotExist) { + dirErr = nil + } + return errors.Join(socketErr, dirErr) + } + + if err := os.Chmod(dir, 0o700); err != nil { //nolint:forbidigo + return nil, nil, errors.Join(fmt.Errorf("set private socket directory permissions: %w", err), cleanup()) + } + + listener, err := net.Listen("unix", socketPath) + if err != nil { + return nil, nil, errors.Join(fmt.Errorf("listen on private socket: %w", err), cleanup()) + } + if err := os.Chmod(socketPath, 0o600); err != nil { //nolint:forbidigo + return nil, nil, errors.Join(fmt.Errorf("set private socket permissions: %w", err), listener.Close(), cleanup()) + } + return listener, cleanup, nil } // GeneratePipePath returns a platform-appropriate pipe path for the given name. func GeneratePipePath(name string) string { //nolint:forbidigo - return path.Join(os.TempDir(), name) + return filepath.Join(os.TempDir(), name) } diff --git a/tsc/internal/api/transport_unix_test.go b/tsc/internal/api/transport_unix_test.go new file mode 100644 index 0000000000000..ffb57920d729f --- /dev/null +++ b/tsc/internal/api/transport_unix_test.go @@ -0,0 +1,103 @@ +//go:build !windows + +package api + +import ( + "errors" + "fmt" + "net" + "os" + "os/exec" + "path/filepath" + "syscall" + "testing" + "time" +) + +const privatePipeHelperEnv = "TSGO_PRIVATE_PIPE_HELPER" + +func TestPipeTransportClosePreservesReplacement(t *testing.T) { + t.Parallel() + + transport, err := NewPrivatePipeTransport("tsgo-api-test") + if err != nil { + t.Fatal(err) + } + socketPath := transport.Path() + if closeErr := transport.Close(); closeErr != nil { + t.Fatal(closeErr) + } + + replacement, err := NewPipeTransport(filepath.Dir(socketPath)) + if err != nil { + t.Fatal(err) + } + defer replacement.Close() + + if closeErr := transport.Close(); !errors.Is(closeErr, net.ErrClosed) { + t.Fatalf("second Close returned %v, want net.ErrClosed", closeErr) + } + conn, err := net.DialTimeout("unix", socketPath, time.Second) + if err != nil { + t.Fatalf("replacement socket is unreachable: %v", err) + } + defer conn.Close() +} + +func TestPipeTransportPermissions(t *testing.T) { + t.Parallel() + + if os.Getenv(privatePipeHelperEnv) == "1" { + testPipeTransportPermissions(t) + return + } + + cmd := exec.Command(os.Args[0], "-test.run=^TestPipeTransportPermissions$") + cmd.Env = append(os.Environ(), privatePipeHelperEnv+"=1") + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("helper process failed: %v\n%s", err, output) + } +} + +func testPipeTransportPermissions(t *testing.T) { + syscall.Umask(0o002) + + transport, err := NewPrivatePipeTransport("tsgo-api-test") + if err != nil { + t.Fatal(err) + } + defer transport.Close() + + socketPath := transport.Path() + assertMode(t, filepath.Dir(socketPath), 0o700) + assertMode(t, socketPath, 0o600) + + explicitDir := filepath.Join(os.TempDir(), fmt.Sprintf("tsgo-api-%d", os.Getpid())) + explicitTransport, err := NewPipeTransport(explicitDir) + if err != nil { + t.Fatal(err) + } + assertMode(t, explicitDir, 0o700) + assertMode(t, filepath.Join(explicitDir, "socket"), 0o600) + + if _, err := NewPipeTransport(explicitDir); err == nil { + t.Fatal("NewPipeTransport succeeded with an existing directory") + } + if err := explicitTransport.Close(); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(explicitDir); !os.IsNotExist(err) { + t.Fatalf("socket directory still exists after close: %v", err) + } +} + +func assertMode(t *testing.T, path string, want os.FileMode) { + t.Helper() + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if got := info.Mode().Perm(); got != want { + t.Fatalf("%s mode = %04o, want %04o", path, got, want) + } +} diff --git a/tsc/internal/api/transport_windows.go b/tsc/internal/api/transport_windows.go index c54967f417113..89d0f711cda02 100644 --- a/tsc/internal/api/transport_windows.go +++ b/tsc/internal/api/transport_windows.go @@ -3,14 +3,27 @@ package api import ( + "fmt" "net" "github.com/Microsoft/go-winio" + "golang.org/x/sys/windows" ) // newPipeListener creates a Windows named pipe listener. -func newPipeListener(path string) (net.Listener, error) { - return winio.ListenPipe(path, nil) +func newPipeListener(path string) (net.Listener, func() error, error) { + user, err := windows.GetCurrentProcessToken().GetTokenUser() + if err != nil { + return nil, nil, fmt.Errorf("get pipe owner SID: %w", err) + } + listener, err := winio.ListenPipe(path, &winio.PipeConfig{ + SecurityDescriptor: "D:P(A;;GA;;;" + user.User.Sid.String() + ")", + }) + return listener, nil, err +} + +func newPrivatePipeListener(name string) (net.Listener, func() error, error) { + return newPipeListener(GeneratePipePath(name)) } // GeneratePipePath returns a platform-appropriate pipe path for the given name. diff --git a/tsc/internal/api/transport_windows_test.go b/tsc/internal/api/transport_windows_test.go new file mode 100644 index 0000000000000..c40489dbf62b2 --- /dev/null +++ b/tsc/internal/api/transport_windows_test.go @@ -0,0 +1,76 @@ +//go:build windows + +package api + +import ( + "fmt" + "os" + "testing" + "time" + + "github.com/Microsoft/go-winio" + "golang.org/x/sys/windows" +) + +func TestPipeTransportPermissions(t *testing.T) { + t.Parallel() + + transport, err := NewPrivatePipeTransport(fmt.Sprintf("tsgo-api-test-%d", os.Getpid())) + if err != nil { + t.Fatal(err) + } + defer transport.Close() + + accepted := make(chan error, 1) + done := make(chan struct{}) + defer close(done) + go func() { + server, acceptErr := transport.Accept() + if acceptErr == nil { + defer server.Close() + } + accepted <- acceptErr + <-done + }() + + timeout := time.Second + conn, err := winio.DialPipe(transport.Path(), &timeout) + if err != nil { + t.Fatalf("current user cannot connect: %v", err) + } + defer conn.Close() + + select { + case acceptErr := <-accepted: + if acceptErr != nil { + t.Fatalf("failed to accept current user connection: %v", acceptErr) + } + case <-time.After(timeout): + t.Fatal("timed out accepting current user connection") + } + + handle := windows.Handle(conn.(interface{ Fd() uintptr }).Fd()) + sd, err := windows.GetSecurityInfo(handle, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION) + if err != nil { + t.Fatal(err) + } + dacl, _, err := sd.DACL() + if err != nil { + t.Fatal(err) + } + if dacl == nil || dacl.AceCount != 1 { + t.Fatalf("expected exactly one access-control entry: %s", sd.String()) + } + user, err := windows.GetCurrentProcessToken().GetTokenUser() + if err != nil { + t.Fatal(err) + } + // Windows maps generic all access to file all access and may abbreviate well-known SIDs. + expected, err := windows.SecurityDescriptorFromString("D:P(A;;FA;;;" + user.User.Sid.String() + ")") + if err != nil { + t.Fatal(err) + } + if got, want := sd.String(), expected.String(); got != want { + t.Fatalf("pipe security descriptor = %s, want %s", got, want) + } +} diff --git a/tsc/internal/lsp/lsproto/_generate/generate.mts b/tsc/internal/lsp/lsproto/_generate/generate.mts index f7d884758a1fa..4d3c5f6ae4824 100755 --- a/tsc/internal/lsp/lsproto/_generate/generate.mts +++ b/tsc/internal/lsp/lsproto/_generate/generate.mts @@ -367,7 +367,7 @@ const customStructures: Structure[] = [ name: "pipe", type: { kind: "base", name: "string" }, optional: true, - documentation: "Optional path to use for the named pipe or Unix domain socket. If not provided, a unique path will be generated.", + documentation: "Optional named pipe path on Windows. On Unix, the path must not exist and will be created as a private directory containing a socket named 'socket'. If not provided, a unique path will be generated.", }, ], documentation: "Parameters for the initializeAPISession request.", diff --git a/tsc/internal/lsp/lsproto/lsp_generated.go b/tsc/internal/lsp/lsproto/lsp_generated.go index 444d9ff4ce103..d000f0ad1df83 100644 --- a/tsc/internal/lsp/lsproto/lsp_generated.go +++ b/tsc/internal/lsp/lsproto/lsp_generated.go @@ -9034,7 +9034,7 @@ func (s *ProfileResult) UnmarshalJSONFrom(dec *json.Decoder) error { // Parameters for the initializeAPISession request. type InitializeAPISessionParams struct { - // Optional path to use for the named pipe or Unix domain socket. If not provided, a unique path will be generated. + // Optional named pipe path on Windows. On Unix, the path must not exist and will be created as a private directory containing a socket named 'socket'. If not provided, a unique path will be generated. Pipe *string `json:"pipe,omitzero"` } diff --git a/tsc/internal/lsp/server.go b/tsc/internal/lsp/server.go index fcf72f5088798..1bcc19201761d 100644 --- a/tsc/internal/lsp/server.go +++ b/tsc/internal/lsp/server.go @@ -1740,15 +1740,13 @@ func (s *Server) handleInitializeAPISession(ctx context.Context, params *lsproto var apiSession *api.Session apiSession = api.NewSession(s.session, nil) - // Use provided pipe path or generate a unique one - var pipePath string + var transport *api.PipeTransport + var err error if params.Pipe != nil && *params.Pipe != "" { - pipePath = *params.Pipe + transport, err = api.NewPipeTransport(*params.Pipe) } else { - pipePath = s.generateAPIPipePath() + transport, err = api.NewPrivatePipeTransport(s.generateAPIPipeName()) } - - transport, err := api.NewPipeTransport(pipePath) if err != nil { return nil, fmt.Errorf("failed to create API transport: %w", err) } @@ -1793,15 +1791,14 @@ func (s *Server) handleInitializeAPISession(ctx context.Context, params *lsproto return &lsproto.InitializeAPISessionResult{ SessionId: apiSession.ID(), - Pipe: pipePath, + Pipe: transport.Path(), }, nil } -func (s *Server) generateAPIPipePath() string { - // Generate a high-entropy path using time and random source +func (s *Server) generateAPIPipeName() string { now := time.Now().UnixNano() rnd := rand.Uint64() - return api.GeneratePipePath(fmt.Sprintf("tsgo-api-%x-%x", now, rnd)) + return fmt.Sprintf("tsgo-api-%x-%x", now, rnd) } func (s *Server) removeAPISession(id string) {