Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion packages/typescript/src/api/syncChannel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
type ChildProcess,
spawn,
} from "node:child_process";
import { randomBytes } from "node:crypto";
import {
closeSync,
openSync,
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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"],
});
Expand Down
2 changes: 1 addition & 1 deletion tools/scripts/lsp/generate.mts
Original file line number Diff line number Diff line change
Expand Up @@ -407,7 +407,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.",
Expand Down
2 changes: 1 addition & 1 deletion tsc/cmd/tsc/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ func parseAPIFlags(args []string) (apiFlags, error) {
flags := flag.NewFlagSet("api", flag.ContinueOnError)
result := apiFlags{}
flags.StringVar(&result.cwd, "cwd", core.Must(os.Getwd()), "current working directory")
flags.StringVar(&result.pipePath, "pipe", "", "use named pipe or Unix domain socket for communication instead of stdio")
flags.StringVar(&result.pipePath, "pipe", "", "use a named pipe (Windows) or create a private Unix socket at <path>/socket, where <path> must not exist")
flags.StringVar(&result.callbacks, "callbacks", "", "comma-separated list of FS callbacks and defaults to enable")
flags.BoolVar(&result.caseSensitive, "useCaseSensitiveFileNames", osvfs.FS().CaseSensitivity().IsCaseSensitive(), "treat filesystem paths as case-sensitive")
flags.BoolVar(&result.async, "async", false, "use JSON-RPC protocol instead of MessagePack (for async API)")
Expand Down
4 changes: 2 additions & 2 deletions tsc/internal/api/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@ type StdioServerOptions struct {
Err io.Writer
Cwd tspath.RootedDirectoryPath
DefaultLibraryPath tspath.RootedDirectoryPath
// 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.
Expand Down
32 changes: 27 additions & 5 deletions tsc/internal/ipc/transport.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package ipc

import (
"errors"
"io"
"net"
)
Expand All @@ -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.
Expand All @@ -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.
Expand Down
53 changes: 46 additions & 7 deletions tsc/internal/ipc/transport_unix.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,59 @@
package ipc

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)
}
103 changes: 103 additions & 0 deletions tsc/internal/ipc/transport_unix_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
//go:build !windows

package ipc

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)
}
}
17 changes: 15 additions & 2 deletions tsc/internal/ipc/transport_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,27 @@
package ipc

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.
Expand Down
76 changes: 76 additions & 0 deletions tsc/internal/ipc/transport_windows_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
//go:build windows

package ipc

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)
}
}
Loading
Loading