From 580600d366937a31ffe6cda9b0849c3fae486f9b Mon Sep 17 00:00:00 2001 From: photostorm Date: Mon, 25 Jul 2022 17:24:45 -0400 Subject: [PATCH] new windows support --- README.md | 4 +- cmd_windows.go | 391 +++++++++++++++++++++++++++++++++++++++++ doc.go | 34 +++- go.mod | 2 + go.sum | 2 + pty_unsupported.go | 4 +- pty_windows.go | 195 ++++++++++++++++++++ run.go | 45 +---- run_unix.go | 69 ++++++++ run_windows.go | 235 +++++++++++++++++++++++++ start.go | 25 --- start_windows.go | 19 -- test_crosscompile.sh | 4 +- winsize.go | 18 +- winsize_unix.go | 13 +- winsize_unsupported.go | 23 --- winsize_windows.go | 89 ++++++++++ 17 files changed, 1035 insertions(+), 137 deletions(-) create mode 100644 cmd_windows.go create mode 100644 go.sum create mode 100644 pty_windows.go create mode 100644 run_unix.go create mode 100644 run_windows.go delete mode 100644 start.go delete mode 100644 start_windows.go delete mode 100644 winsize_unsupported.go create mode 100644 winsize_windows.go diff --git a/README.md b/README.md index a4fe767..df4bcce 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # pty -Pty is a Go package for using unix pseudo-terminals. +Pty is a Go package for using unix pseudo-terminals and windows ConPty. ## Install @@ -12,6 +12,8 @@ go get github.com/creack/pty Note that those examples are for demonstration purpose only, to showcase how to use the library. They are not meant to be used in any kind of production environment. +__NOTE:__ This package requires `ConPty` support on windows platform, please make sure your windows system meet [these requirements](https://docs.microsoft.com/en-us/windows/console/createpseudoconsole#requirements) + ### Command ```go diff --git a/cmd_windows.go b/cmd_windows.go new file mode 100644 index 0000000..200e18a --- /dev/null +++ b/cmd_windows.go @@ -0,0 +1,391 @@ +//go:build windows +// +build windows + +package pty + +import ( + "bytes" + "errors" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + "syscall" + "unicode/utf16" + "unsafe" + + "golang.org/x/sys/windows" +) + +// copied from os/exec.Cmd for platform compatibility +// we need to use startupInfoEx for pty support, but os/exec.Cmd only have +// support for startupInfo on windows, so we have to rewrite some internal +// logic for windows while keep its behavior compatible with other platforms. + +// WindowExecCmd represents an external command being prepared or run. +// +// A cmd cannot be reused after calling its Run, Output or CombinedOutput +// methods. +type WindowExecCmd struct { + cmd *exec.Cmd + waitCalled bool + consoleHandle syscall.Handle + tty *WindowsTty + conPty *WindowsPty + attrList *windows.ProcThreadAttributeListContainer +} + +var errProcessNotStarted = errors.New("exec: process has not started yet") + +func (c *WindowExecCmd) close() error { + c.attrList.Delete() + _ = c.conPty.Close() + _ = c.tty.Close() + return nil +} + +func (c *WindowExecCmd) Run() error { + err := c.Start() + if err != nil { + return err + } + + return c.Wait() +} + +func (c *WindowExecCmd) Wait() error { + if c.cmd.Process == nil { + return errProcessNotStarted + } + + var err error + + if c.waitCalled { + return errors.New("exec: wait was already called") + } + + c.waitCalled = true + c.cmd.ProcessState, err = c.cmd.Process.Wait() + if err != nil { + return err + } + + err = c.close() + if err != nil { + return err + } + + if !c.cmd.ProcessState.Success() { + return &exec.ExitError{ProcessState: c.cmd.ProcessState} + } + + return nil +} + +func (c *WindowExecCmd) StdinPipe() (io.WriteCloser, error) { + if c.cmd.Stdin != nil { + return nil, errors.New("exec: Stdin already set") + } + if c.cmd.Process != nil { + return nil, errors.New("exec: StdinPipe after process started") + } + + if c.conPty != nil { + return c.conPty.InputPipe(), nil + } + + return nil, ErrUnsupported +} + +func (c *WindowExecCmd) StdoutPipe() (io.ReadCloser, error) { + if c.cmd.Stdout != nil { + return nil, errors.New("exec: Stdout already set") + } + if c.cmd.Process != nil { + return nil, errors.New("exec: StdoutPipe after process started") + } + + if c.conPty != nil { + return c.conPty.OutputPipe(), nil + } + + return nil, ErrUnsupported +} + +func (c *WindowExecCmd) StderrPipe() (io.ReadCloser, error) { + if c.cmd.Stderr != nil { + return nil, errors.New("exec: Stderr already set") + } + if c.cmd.Process != nil { + return nil, errors.New("exec: StderrPipe after process started") + } + + if c.conPty != nil { + return c.conPty.OutputPipe(), nil + } + + return nil, ErrUnsupported +} + +func (c *WindowExecCmd) Output() ([]byte, error) { + if c.cmd.Stdout != nil { + return nil, errors.New("exec: Stdout already set") + } + + var stdout bytes.Buffer + c.cmd.Stdout = &stdout + + err := c.Run() + return stdout.Bytes(), err +} + +func (c *WindowExecCmd) CombinedOutput() ([]byte, error) { + if c.cmd.Stdout != nil { + return nil, errors.New("exec: Stdout already set") + } + if c.cmd.Stderr != nil { + return nil, errors.New("exec: Stderr already set") + } + var b bytes.Buffer + c.cmd.Stdout = &b + c.cmd.Stderr = &b + err := c.Run() + return b.Bytes(), err +} + +func (c *WindowExecCmd) argv() []string { + if len(c.cmd.Args) > 0 { + return c.cmd.Args + } + + return []string{c.cmd.Path} +} + +// +// Helpers for working with Windows. These are exact copies of the same utilities found in the go stdlib. +// + +func lookExtensions(path, dir string) (string, error) { + if filepath.Base(path) == path { + path = filepath.Join(".", path) + } + + if dir == "" { + return exec.LookPath(path) + } + + if filepath.VolumeName(path) != "" { + return exec.LookPath(path) + } + + if len(path) > 1 && os.IsPathSeparator(path[0]) { + return exec.LookPath(path) + } + + dirandpath := filepath.Join(dir, path) + + // We assume that LookPath will only add file extension. + lp, err := exec.LookPath(dirandpath) + if err != nil { + return "", err + } + + ext := strings.TrimPrefix(lp, dirandpath) + + return path + ext, nil +} + +func dedupEnvCase(caseInsensitive bool, env []string) []string { + // Construct the output in reverse order, to preserve the + // last occurrence of each key. + out := make([]string, 0, len(env)) + saw := make(map[string]bool, len(env)) + for n := len(env); n > 0; n-- { + kv := env[n-1] + + i := strings.Index(kv, "=") + if i == 0 { + // We observe in practice keys with a single leading "=" on Windows. + // TODO(#49886): Should we consume only the first leading "=" as part + // of the key, or parse through arbitrarily many of them until a non-"="? + i = strings.Index(kv[1:], "=") + 1 + } + if i < 0 { + if kv != "" { + // The entry is not of the form "key=value" (as it is required to be). + // Leave it as-is for now. + // TODO(#52436): should we strip or reject these bogus entries? + out = append(out, kv) + } + continue + } + k := kv[:i] + if caseInsensitive { + k = strings.ToLower(k) + } + if saw[k] { + continue + } + + saw[k] = true + out = append(out, kv) + } + + // Now reverse the slice to restore the original order. + for i := 0; i < len(out)/2; i++ { + j := len(out) - i - 1 + out[i], out[j] = out[j], out[i] + } + + return out +} + +func addCriticalEnv(env []string) []string { + for _, kv := range env { + eq := strings.Index(kv, "=") + if eq < 0 { + continue + } + k := kv[:eq] + if strings.EqualFold(k, "SYSTEMROOT") { + // We already have it. + return env + } + } + + return append(env, "SYSTEMROOT="+os.Getenv("SYSTEMROOT")) +} + +func execEnvDefault(sys *syscall.SysProcAttr) (env []string, err error) { + if sys == nil || sys.Token == 0 { + return syscall.Environ(), nil + } + + var block *uint16 + err = windows.CreateEnvironmentBlock(&block, windows.Token(sys.Token), false) + if err != nil { + return nil, err + } + + defer windows.DestroyEnvironmentBlock(block) + blockp := uintptr(unsafe.Pointer(block)) + + for { + + // find NUL terminator + end := unsafe.Pointer(blockp) + for *(*uint16)(end) != 0 { + end = unsafe.Pointer(uintptr(end) + 2) + } + + n := (uintptr(end) - uintptr(unsafe.Pointer(blockp))) / 2 + if n == 0 { + // environment block ends with empty string + break + } + + entry := (*[(1 << 30) - 1]uint16)(unsafe.Pointer(blockp))[:n:n] + env = append(env, string(utf16.Decode(entry))) + blockp += 2 * (uintptr(len(entry)) + 1) + } + return +} + +func createEnvBlock(envv []string) *uint16 { + if len(envv) == 0 { + return &utf16.Encode([]rune("\x00\x00"))[0] + } + length := 0 + for _, s := range envv { + length += len(s) + 1 + } + length += 1 + + b := make([]byte, length) + i := 0 + for _, s := range envv { + l := len(s) + copy(b[i:i+l], []byte(s)) + copy(b[i+l:i+l+1], []byte{0}) + i = i + l + 1 + } + copy(b[i:i+1], []byte{0}) + + return &utf16.Encode([]rune(string(b)))[0] +} + +func makeCmdLine(args []string) string { + var s string + for _, v := range args { + if s != "" { + s += " " + } + s += windows.EscapeArg(v) + } + return s +} + +func isSlash(c uint8) bool { + return c == '\\' || c == '/' +} + +func normalizeDir(dir string) (name string, err error) { + ndir, err := syscall.FullPath(dir) + if err != nil { + return "", err + } + if len(ndir) > 2 && isSlash(ndir[0]) && isSlash(ndir[1]) { + // dir cannot have \\server\share\path form + return "", syscall.EINVAL + } + return ndir, nil +} + +func volToUpper(ch int) int { + if 'a' <= ch && ch <= 'z' { + ch += 'A' - 'a' + } + return ch +} + +func joinExeDirAndFName(dir, p string) (name string, err error) { + if len(p) == 0 { + return "", syscall.EINVAL + } + if len(p) > 2 && isSlash(p[0]) && isSlash(p[1]) { + // \\server\share\path form + return p, nil + } + if len(p) > 1 && p[1] == ':' { + // has drive letter + if len(p) == 2 { + return "", syscall.EINVAL + } + if isSlash(p[2]) { + return p, nil + } else { + d, err := normalizeDir(dir) + if err != nil { + return "", err + } + if volToUpper(int(p[0])) == volToUpper(int(d[0])) { + return syscall.FullPath(d + "\\" + p[2:]) + } else { + return syscall.FullPath(p) + } + } + } else { + // no drive letter + d, err := normalizeDir(dir) + if err != nil { + return "", err + } + + if isSlash(p[0]) { + return windows.FullPath(d[:2] + p) + } else { + return windows.FullPath(d + "\\" + p) + } + } +} diff --git a/doc.go b/doc.go index 3c8b324..033d43a 100644 --- a/doc.go +++ b/doc.go @@ -3,7 +3,7 @@ package pty import ( "errors" - "os" + "io" ) // ErrUnsupported is returned if a function is not @@ -11,6 +11,36 @@ import ( var ErrUnsupported = errors.New("unsupported") // Open a pty and its corresponding tty. -func Open() (pty, tty *os.File, err error) { +func Open() (Pty, Tty, error) { return open() } + +type FdHolder interface { + Fd() uintptr +} + +// Pty for terminal control in current process +// for unix systems, the real type is *os.File +// for windows, the real type is a *WindowsPty for ConPTY handle +type Pty interface { + // FdHolder Fd intended to resize Tty of child process in current process + FdHolder + + Name() string + + // WriteString is only used to identify Pty and Tty + WriteString(s string) (n int, err error) + io.ReadWriteCloser +} + +// Tty for data i/o in child process +// for unix systems, the real type is *os.File +// for windows, the real type is a *WindowsTty, which is a combination of two pipe file +type Tty interface { + // FdHolder Fd only intended for manual InheritSize from Pty + FdHolder + + Name() string + + io.ReadWriteCloser +} diff --git a/go.mod b/go.mod index 7731235..332c3a4 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,5 @@ module github.com/creack/pty go 1.13 + +require golang.org/x/sys v0.0.0-20220721230656-c6bc011c0c49 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..ba6707d --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +golang.org/x/sys v0.0.0-20220721230656-c6bc011c0c49 h1:TMjZDarEwf621XDryfitp/8awEhiZNiwgphKlTMGRIg= +golang.org/x/sys v0.0.0-20220721230656-c6bc011c0c49/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/pty_unsupported.go b/pty_unsupported.go index c771020..c0ef327 100644 --- a/pty_unsupported.go +++ b/pty_unsupported.go @@ -1,5 +1,5 @@ -//go:build !linux && !darwin && !freebsd && !dragonfly && !netbsd && !openbsd && !solaris -// +build !linux,!darwin,!freebsd,!dragonfly,!netbsd,!openbsd,!solaris +//go:build !linux && !darwin && !freebsd && !dragonfly && !netbsd && !openbsd && !solaris && !windows +// +build !linux,!darwin,!freebsd,!dragonfly,!netbsd,!openbsd,!solaris,!windows package pty diff --git a/pty_windows.go b/pty_windows.go new file mode 100644 index 0000000..04fda46 --- /dev/null +++ b/pty_windows.go @@ -0,0 +1,195 @@ +//go:build windows +// +build windows + +package pty + +import ( + "fmt" + "os" + "syscall" + "unsafe" + + "golang.org/x/sys/windows" +) + +const ( + PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE = 0x20016 +) + +type WindowsPty struct { + handle windows.Handle + r, w *os.File +} + +type WindowsTty struct { + handle windows.Handle + r, w *os.File +} + +var ( + // NOTE(security): as noted by the comment of syscall.NewLazyDLL and syscall.LoadDLL + // user need to call internal/syscall/windows/sysdll.Add("kernel32.dll") to make sure + // the kernel32.dll is loaded from windows system path + // + // ref: https://pkg.go.dev/syscall@go1.13?GOOS=windows#LoadDLL + kernel32DLL = windows.NewLazyDLL("kernel32.dll") + + // https://docs.microsoft.com/en-us/windows/console/createpseudoconsole + createPseudoConsole = kernel32DLL.NewProc("CreatePseudoConsole") + closePseudoConsole = kernel32DLL.NewProc("ClosePseudoConsole") + + resizePseudoConsole = kernel32DLL.NewProc("ResizePseudoConsole") + getConsoleScreenBufferInfo = kernel32DLL.NewProc("GetConsoleScreenBufferInfo") +) + +func open() (_ Pty, _ Tty, err error) { + pr, consoleW, err := os.Pipe() + if err != nil { + return nil, nil, err + } + + consoleR, pw, err := os.Pipe() + if err != nil { + _ = consoleW.Close() + _ = pr.Close() + return nil, nil, err + } + + var consoleHandle windows.Handle + + err = procCreatePseudoConsole(windows.Handle(consoleR.Fd()), windows.Handle(consoleW.Fd()), + 0, &consoleHandle) + if err != nil { + _ = consoleW.Close() + _ = pr.Close() + _ = pw.Close() + _ = consoleR.Close() + return nil, nil, err + } + + // These pipes can be closed here without any worry + err = consoleW.Close() + if err != nil { + return nil, nil, fmt.Errorf("failed to close pseudo console handle: %w", err) + } + + err = consoleR.Close() + if err != nil { + return nil, nil, fmt.Errorf("failed to close pseudo console handle: %w", err) + } + + return &WindowsPty{ + handle: consoleHandle, + r: pr, + w: pw, + }, &WindowsTty{ + handle: consoleHandle, + r: consoleR, + w: consoleW, + }, nil +} + +func (p *WindowsPty) Name() string { + return p.r.Name() +} + +func (p *WindowsPty) Fd() uintptr { + return uintptr(p.handle) +} + +func (p *WindowsPty) Read(data []byte) (int, error) { + return p.r.Read(data) +} + +func (p *WindowsPty) Write(data []byte) (int, error) { + return p.w.Write(data) +} + +func (p *WindowsPty) WriteString(s string) (int, error) { + return p.w.WriteString(s) +} + +func (p *WindowsPty) InputPipe() *os.File { + return p.w +} + +func (p *WindowsPty) OutputPipe() *os.File { + return p.r +} + +func (p *WindowsPty) UpdateProcThreadAttribute(attrList *windows.ProcThreadAttributeListContainer) error { + var err error + + if err = attrList.Update( + PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE, + unsafe.Pointer(p.handle), + unsafe.Sizeof(p.handle), + ); err != nil { + return fmt.Errorf("failed to update proc thread attributes for pseudo console: %w", err) + } + + return nil +} + +func (p *WindowsPty) Close() error { + _ = p.r.Close() + _ = p.w.Close() + + err := closePseudoConsole.Find() + if err != nil { + return err + } + + _, _, err = closePseudoConsole.Call(uintptr(p.handle)) + return err +} + +func (t *WindowsTty) Name() string { + return t.r.Name() +} + +func (t *WindowsTty) Fd() uintptr { + return uintptr(t.handle) +} + +func (t *WindowsTty) Read(p []byte) (int, error) { + return t.r.Read(p) +} + +func (t *WindowsTty) Write(p []byte) (int, error) { + return t.w.Write(p) +} + +func (t *WindowsTty) Close() error { + _ = t.r.Close() + return t.w.Close() +} + +func procCreatePseudoConsole(hInput windows.Handle, hOutput windows.Handle, dwFlags uint32, consoleHandle *windows.Handle) error { + var r0 uintptr + var err error + + err = createPseudoConsole.Find() + if err != nil { + return err + } + + r0, _, err = createPseudoConsole.Call( + (windowsCoord{X: 80, Y: 30}).Pack(), // size: default 80x30 window + uintptr(hInput), // console input + uintptr(hOutput), // console output + uintptr(dwFlags), // console flags, currently only PSEUDOCONSOLE_INHERIT_CURSOR supported + uintptr(unsafe.Pointer(consoleHandle)), // console handler value return + ) + + if int32(r0) < 0 { + if r0&0x1fff0000 == 0x00070000 { + r0 &= 0xffff + } + + // S_OK: 0 + return syscall.Errno(r0) + } + + return nil +} diff --git a/run.go b/run.go index 4755366..0b97b94 100644 --- a/run.go +++ b/run.go @@ -1,9 +1,7 @@ package pty import ( - "os" "os/exec" - "syscall" ) // Start assigns a pseudo-terminal tty os.File to c.Stdin, c.Stdout, @@ -11,47 +9,6 @@ import ( // corresponding pty. // // Starts the process in a new session and sets the controlling terminal. -func Start(cmd *exec.Cmd) (*os.File, error) { +func Start(cmd *exec.Cmd) (Pty, error) { return StartWithSize(cmd, nil) } - -// StartWithAttrs assigns a pseudo-terminal tty os.File to c.Stdin, c.Stdout, -// and c.Stderr, calls c.Start, and returns the File of the tty's -// corresponding pty. -// -// This will resize the pty to the specified size before starting the command if a size is provided. -// The `attrs` parameter overrides the one set in c.SysProcAttr. -// -// This should generally not be needed. Used in some edge cases where it is needed to create a pty -// without a controlling terminal. -func StartWithAttrs(c *exec.Cmd, sz *Winsize, attrs *syscall.SysProcAttr) (*os.File, error) { - pty, tty, err := Open() - if err != nil { - return nil, err - } - defer func() { _ = tty.Close() }() // Best effort. - - if sz != nil { - if err := Setsize(pty, sz); err != nil { - _ = pty.Close() // Best effort. - return nil, err - } - } - if c.Stdout == nil { - c.Stdout = tty - } - if c.Stderr == nil { - c.Stderr = tty - } - if c.Stdin == nil { - c.Stdin = tty - } - - c.SysProcAttr = attrs - - if err := c.Start(); err != nil { - _ = pty.Close() // Best effort. - return nil, err - } - return pty, err -} diff --git a/run_unix.go b/run_unix.go new file mode 100644 index 0000000..6d43291 --- /dev/null +++ b/run_unix.go @@ -0,0 +1,69 @@ +//go:build !windows +// +build !windows + +package pty + +import ( + "os/exec" + "syscall" +) + +// StartWithSize assigns a pseudo-terminal Tty to c.Stdin, c.Stdout, +// and c.Stderr, calls c.Start, and returns the File of the tty's +// corresponding Pty. +// +// This will resize the Pty to the specified size before starting the command. +// Starts the process in a new session and sets the controlling terminal. +func StartWithSize(c *exec.Cmd, sz *Winsize) (Pty, error) { + if c.SysProcAttr == nil { + c.SysProcAttr = &syscall.SysProcAttr{} + } + c.SysProcAttr.Setsid = true + c.SysProcAttr.Setctty = true + return StartWithAttrs(c, sz, c.SysProcAttr) +} + +// StartWithAttrs assigns a pseudo-terminal Tty to c.Stdin, c.Stdout, +// and c.Stderr, calls c.Start, and returns the File of the tty's +// corresponding Pty. +// +// This will resize the Pty to the specified size before starting the command if a size is provided. +// The `attrs` parameter overrides the one set in c.SysProcAttr. +// +// This should generally not be needed. Used in some edge cases where it is needed to create a pty +// without a controlling terminal. +func StartWithAttrs(c *exec.Cmd, sz *Winsize, attrs *syscall.SysProcAttr) (Pty, error) { + pty, tty, err := open() + if err != nil { + return nil, err + } + defer func() { + // always close tty fds since it's being used in another process + // but pty is kept to resize tty + _ = tty.Close() + }() + + if sz != nil { + if err := Setsize(pty, sz); err != nil { + _ = pty.Close() + return nil, err + } + } + if c.Stdout == nil { + c.Stdout = tty + } + if c.Stderr == nil { + c.Stderr = tty + } + if c.Stdin == nil { + c.Stdin = tty + } + + c.SysProcAttr = attrs + + if err := c.Start(); err != nil { + _ = pty.Close() + return nil, err + } + return pty, err +} diff --git a/run_windows.go b/run_windows.go new file mode 100644 index 0000000..e4da40d --- /dev/null +++ b/run_windows.go @@ -0,0 +1,235 @@ +//go:build windows +// +build windows + +package pty + +import ( + "errors" + "fmt" + "os" + "os/exec" + "syscall" + "unsafe" + + "golang.org/x/sys/windows" +) + +// StartWithSize assigns a pseudo-terminal Tty to c.Stdin, c.Stdout, +// and c.Stderr, calls c.Start, and returns the File of the tty's +// corresponding Pty. +// +// This will resize the Pty to the specified size before starting the command. +// Starts the process in a new session and sets the controlling terminal. +func StartWithSize(c *exec.Cmd, sz *Winsize) (Pty, error) { + return StartWithAttrs(c, sz, c.SysProcAttr) +} + +// StartWithAttrs assigns a pseudo-terminal Tty to c.Stdin, c.Stdout, +// and c.Stderr, calls c.Start, and returns the File of the tty's +// corresponding Pty. +// +// This will resize the Pty to the specified size before starting the command if a size is provided. +// The `attrs` parameter overrides the one set in c.SysProcAttr. +// +// This should generally not be needed. Used in some edge cases where it is needed to create a pty +// without a controlling terminal. +func StartWithAttrs(c *exec.Cmd, sz *Winsize, attrs *syscall.SysProcAttr) (_ Pty, err error) { + pty, tty, err := open() + if err != nil { + return nil, err + } + + defer func() { + // unlike unix command exec, do not close tty unless error happened + if err != nil { + _ = tty.Close() + _ = pty.Close() + } + }() + + if sz != nil { + if err = Setsize(pty, sz); err != nil { + return nil, err + } + } + + // unlike unix command exec, do not set stdin/stdout/stderr + + c.SysProcAttr = attrs + + // do not use os/exec.Start since we need to append console handler to startup info + + w := WindowExecCmd{ + cmd: c, + waitCalled: false, + consoleHandle: syscall.Handle(tty.Fd()), + tty: tty.(*WindowsTty), + conPty: pty.(*WindowsPty), + } + + err = w.Start() + if err != nil { + _ = tty.Close() + _ = pty.Close() + return nil, err + } + + return pty, err +} + +// Start the specified command but does not wait for it to complete. +// +// If Start returns successfully, the c.Process field will be set. +// +// The Wait method will return the exit code and release associated resources +// once the command exits. +func (c *WindowExecCmd) Start() error { + if c.cmd.Process != nil { + return errors.New("exec: already started") + } + + var argv0 = c.cmd.Path + var argv0p *uint16 + var argvp *uint16 + var dirp *uint16 + var err error + + sys := c.cmd.SysProcAttr + if sys == nil { + sys = &syscall.SysProcAttr{} + } + + if c.cmd.Env == nil { + c.cmd.Env, err = execEnvDefault(sys) + if err != nil { + return err + } + } + + var lp string + + lp, err = lookExtensions(c.cmd.Path, c.cmd.Dir) + if err != nil { + return err + } + + c.cmd.Path = lp + + if len(c.cmd.Dir) != 0 { + // Windows CreateProcess looks for argv0 relative to the current + // directory, and, only once the new process is started, it does + // Chdir(attr.Dir). We are adjusting for that difference here by + // making argv0 absolute. + + argv0, err = joinExeDirAndFName(c.cmd.Dir, c.cmd.Path) + if err != nil { + return err + } + } + + argv0p, err = syscall.UTF16PtrFromString(argv0) + if err != nil { + return err + } + + var cmdline string + + // Windows CreateProcess takes the command line as a single string: + // use attr.CmdLine if set, else build the command line by escaping + // and joining each argument with spaces + if sys.CmdLine != "" { + cmdline = sys.CmdLine + } else { + cmdline = makeCmdLine(c.argv()) + } + + if len(cmdline) != 0 { + argvp, err = windows.UTF16PtrFromString(cmdline) + if err != nil { + return err + } + } + + if len(c.cmd.Dir) != 0 { + dirp, err = windows.UTF16PtrFromString(c.cmd.Dir) + if err != nil { + return err + } + } + + // Acquire the fork lock so that no other threads + // create new fds that are not yet close-on-exec + // before we fork. + syscall.ForkLock.Lock() + defer syscall.ForkLock.Unlock() + + siEx := new(windows.StartupInfoEx) + siEx.Flags = windows.STARTF_USESTDHANDLES + + if sys.HideWindow { + siEx.Flags |= syscall.STARTF_USESHOWWINDOW + siEx.ShowWindow = syscall.SW_HIDE + } + + pi := new(windows.ProcessInformation) + + // Need EXTENDED_STARTUPINFO_PRESENT as we're making use of the attribute list field. + flags := sys.CreationFlags | uint32(windows.CREATE_UNICODE_ENVIRONMENT) | windows.EXTENDED_STARTUPINFO_PRESENT + + c.attrList, err = windows.NewProcThreadAttributeList(3) + if err != nil { + return fmt.Errorf("failed to initialize process thread attribute list: %w", err) + } + + if c.conPty != nil { + if err = c.conPty.UpdateProcThreadAttribute(c.attrList); err != nil { + return err + } + } + + siEx.ProcThreadAttributeList = c.attrList.List() + siEx.Cb = uint32(unsafe.Sizeof(*siEx)) + + if sys.Token != 0 { + err = windows.CreateProcessAsUser( + windows.Token(sys.Token), + argv0p, + argvp, + nil, + nil, + false, + flags, + createEnvBlock(addCriticalEnv(dedupEnvCase(true, c.cmd.Env))), + dirp, + &siEx.StartupInfo, + pi, + ) + } else { + err = windows.CreateProcess( + argv0p, + argvp, + nil, + nil, + false, + flags, + createEnvBlock(addCriticalEnv(dedupEnvCase(true, c.cmd.Env))), + dirp, + &siEx.StartupInfo, + pi, + ) + } + if err != nil { + return err + } + + defer func() { + _ = windows.CloseHandle(pi.Thread) + }() + + c.cmd.Process, err = os.FindProcess(int(pi.ProcessId)) + if err != nil { + return err + } + + return nil +} diff --git a/start.go b/start.go deleted file mode 100644 index 9b51635..0000000 --- a/start.go +++ /dev/null @@ -1,25 +0,0 @@ -//go:build !windows -// +build !windows - -package pty - -import ( - "os" - "os/exec" - "syscall" -) - -// StartWithSize assigns a pseudo-terminal tty os.File to c.Stdin, c.Stdout, -// and c.Stderr, calls c.Start, and returns the File of the tty's -// corresponding pty. -// -// This will resize the pty to the specified size before starting the command. -// Starts the process in a new session and sets the controlling terminal. -func StartWithSize(cmd *exec.Cmd, ws *Winsize) (*os.File, error) { - if cmd.SysProcAttr == nil { - cmd.SysProcAttr = &syscall.SysProcAttr{} - } - cmd.SysProcAttr.Setsid = true - cmd.SysProcAttr.Setctty = true - return StartWithAttrs(cmd, ws, cmd.SysProcAttr) -} diff --git a/start_windows.go b/start_windows.go deleted file mode 100644 index 7e9530b..0000000 --- a/start_windows.go +++ /dev/null @@ -1,19 +0,0 @@ -//go:build windows -// +build windows - -package pty - -import ( - "os" - "os/exec" -) - -// StartWithSize assigns a pseudo-terminal tty os.File to c.Stdin, c.Stdout, -// and c.Stderr, calls c.Start, and returns the File of the tty's -// corresponding pty. -// -// This will resize the pty to the specified size before starting the command. -// Starts the process in a new session and sets the controlling terminal. -func StartWithSize(cmd *exec.Cmd, ws *Winsize) (*os.File, error) { - return nil, ErrUnsupported -} diff --git a/test_crosscompile.sh b/test_crosscompile.sh index 47e8b10..5bf80f9 100755 --- a/test_crosscompile.sh +++ b/test_crosscompile.sh @@ -32,9 +32,7 @@ cross netbsd amd64 386 arm arm64 cross openbsd amd64 386 arm arm64 cross dragonfly amd64 cross solaris amd64 - -# Not expected to work but should still compile. -cross windows amd64 386 arm +cross windows amd64 386 arm # TODO: Fix compilation error on openbsd/arm. # TODO: Merge the solaris PR. diff --git a/winsize.go b/winsize.go index 57323f4..7d3d1fc 100644 --- a/winsize.go +++ b/winsize.go @@ -1,15 +1,22 @@ package pty -import "os" +// Winsize describes the terminal size. +type Winsize struct { + Rows uint16 // ws_row: Number of rows (in cells) + Cols uint16 // ws_col: Number of columns (in cells) + X uint16 // ws_xpixel: Width in pixels + Y uint16 // ws_ypixel: Height in pixels +} // InheritSize applies the terminal size of pty to tty. This should be run // in a signal handler for syscall.SIGWINCH to automatically resize the tty when // the pty receives a window size change notification. -func InheritSize(pty, tty *os.File) error { +func InheritSize(pty Pty, tty Tty) error { size, err := GetsizeFull(pty) if err != nil { return err } + if err := Setsize(tty, size); err != nil { return err } @@ -18,10 +25,7 @@ func InheritSize(pty, tty *os.File) error { // Getsize returns the number of rows (lines) and cols (positions // in each line) in terminal t. -func Getsize(t *os.File) (rows, cols int, err error) { +func Getsize(t FdHolder) (rows, cols int, err error) { ws, err := GetsizeFull(t) - if err != nil { - return 0, 0, err - } - return int(ws.Rows), int(ws.Cols), nil + return int(ws.Rows), int(ws.Cols), err } diff --git a/winsize_unix.go b/winsize_unix.go index 5d99c3d..d3e7d15 100644 --- a/winsize_unix.go +++ b/winsize_unix.go @@ -4,27 +4,18 @@ package pty import ( - "os" "syscall" "unsafe" ) -// Winsize describes the terminal size. -type Winsize struct { - Rows uint16 // ws_row: Number of rows (in cells) - Cols uint16 // ws_col: Number of columns (in cells) - X uint16 // ws_xpixel: Width in pixels - Y uint16 // ws_ypixel: Height in pixels -} - // Setsize resizes t to s. -func Setsize(t *os.File, ws *Winsize) error { +func Setsize(t FdHolder, ws *Winsize) error { //nolint:gosec // Expected unsafe pointer for Syscall call. return ioctl(t.Fd(), syscall.TIOCSWINSZ, uintptr(unsafe.Pointer(ws))) } // GetsizeFull returns the full terminal size description. -func GetsizeFull(t *os.File) (size *Winsize, err error) { +func GetsizeFull(t FdHolder) (size *Winsize, err error) { var ws Winsize //nolint:gosec // Expected unsafe pointer for Syscall call. diff --git a/winsize_unsupported.go b/winsize_unsupported.go deleted file mode 100644 index 0d21099..0000000 --- a/winsize_unsupported.go +++ /dev/null @@ -1,23 +0,0 @@ -//go:build windows -// +build windows - -package pty - -import ( - "os" -) - -// Winsize is a dummy struct to enable compilation on unsupported platforms. -type Winsize struct { - Rows, Cols, X, Y uint16 -} - -// Setsize resizes t to s. -func Setsize(*os.File, *Winsize) error { - return ErrUnsupported -} - -// GetsizeFull returns the full terminal size description. -func GetsizeFull(*os.File) (*Winsize, error) { - return nil, ErrUnsupported -} diff --git a/winsize_windows.go b/winsize_windows.go new file mode 100644 index 0000000..5b96004 --- /dev/null +++ b/winsize_windows.go @@ -0,0 +1,89 @@ +//go:build windows +// +build windows + +package pty + +import ( + "syscall" + "unsafe" +) + +// types from golang.org/x/sys/windows +// copy of https://pkg.go.dev/golang.org/x/sys/windows#Coord +type windowsCoord struct { + X int16 + Y int16 +} + +// copy of https://pkg.go.dev/golang.org/x/sys/windows#SmallRect +type windowsSmallRect struct { + Left int16 + Top int16 + Right int16 + Bottom int16 +} + +// copy of https://pkg.go.dev/golang.org/x/sys/windows#ConsoleScreenBufferInfo +type windowsConsoleScreenBufferInfo struct { + Size windowsCoord + CursorPosition windowsCoord + Attributes uint16 + Window windowsSmallRect + MaximumWindowSize windowsCoord +} + +func (c windowsCoord) Pack() uintptr { + return uintptr((int32(c.Y) << 16) | int32(c.X)) +} + +// Setsize resizes t to ws. +func Setsize(t FdHolder, ws *Winsize) error { + var r0 uintptr + var err error + + err = resizePseudoConsole.Find() + if err != nil { + return err + } + + r0, _, err = resizePseudoConsole.Call( + t.Fd(), + (windowsCoord{X: int16(ws.Cols), Y: int16(ws.Rows)}).Pack(), + ) + if int32(r0) < 0 { + if r0&0x1fff0000 == 0x00070000 { + r0 &= 0xffff + } + + // S_OK: 0 + return syscall.Errno(r0) + } + + return nil +} + +// GetsizeFull returns the full terminal size description. +func GetsizeFull(t FdHolder) (size *Winsize, err error) { + err = getConsoleScreenBufferInfo.Find() + if err != nil { + return nil, err + } + + var info windowsConsoleScreenBufferInfo + var r0 uintptr + + r0, _, err = getConsoleScreenBufferInfo.Call(t.Fd(), uintptr(unsafe.Pointer(&info))) + if int32(r0) < 0 { + if r0&0x1fff0000 == 0x00070000 { + r0 &= 0xffff + } + + // S_OK: 0 + return nil, syscall.Errno(r0) + } + + return &Winsize{ + Rows: uint16(info.Window.Bottom - info.Window.Top + 1), + Cols: uint16(info.Window.Right - info.Window.Left + 1), + }, nil +}