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..5e74ed4 --- /dev/null +++ b/cmd_windows.go @@ -0,0 +1,146 @@ +//go:build windows +// +build windows + +package pty + +import ( + "context" + "io" + "os" + "syscall" + + _ "unsafe" // for go:linkname +) + +// 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. + +// cmd represents an external command being prepared or run. +// +// A cmd cannot be reused after calling its Run, Output or CombinedOutput +// methods. +//go:linkname cmd os/exec.Cmd +type cmd struct { + // Path is the path of the command to run. + // + // This is the only field that must be set to a non-zero + // value. If Path is relative, it is evaluated relative + // to Dir. + Path string + + // Args holds command line arguments, including the command as Args[0]. + // If the Args field is empty or nil, Run uses {Path}. + // + // In typical use, both Path and Args are set by calling Command. + Args []string + + // Env specifies the environment of the process. + // Each entry is of the form "key=value". + // If Env is nil, the new process uses the current process's + // environment. + // If Env contains duplicate environment keys, only the last + // value in the slice for each duplicate key is used. + // As a special case on Windows, SYSTEMROOT is always added if + // missing and not explicitly set to the empty string. + Env []string + + // Dir specifies the working directory of the command. + // If Dir is the empty string, Run runs the command in the + // calling process's current directory. + Dir string + + // Stdin specifies the process's standard input. + // + // If Stdin is nil, the process reads from the null device (os.DevNull). + // + // If Stdin is an *os.File, the process's standard input is connected + // directly to that file. + // + // Otherwise, during the execution of the command a separate + // goroutine reads from Stdin and delivers that data to the command + // over a pipe. In this case, Wait does not complete until the goroutine + // stops copying, either because it has reached the end of Stdin + // (EOF or a read error) or because writing to the pipe returned an error. + Stdin io.Reader + + // Stdout and Stderr specify the process's standard output and error. + // + // If either is nil, Run connects the corresponding file descriptor + // to the null device (os.DevNull). + // + // If either is an *os.File, the corresponding output from the process + // is connected directly to that file. + // + // Otherwise, during the execution of the command a separate goroutine + // reads from the process over a pipe and delivers that data to the + // corresponding Writer. In this case, Wait does not complete until the + // goroutine reaches EOF or encounters an error. + // + // If Stdout and Stderr are the same writer, and have a type that can + // be compared with ==, at most one goroutine at a time will call Write. + Stdout io.Writer + Stderr io.Writer + + // ExtraFiles specifies additional open files to be inherited by the + // new process. It does not include standard input, standard output, or + // standard error. If non-nil, entry i becomes file descriptor 3+i. + // + // ExtraFiles is not supported on Windows. + ExtraFiles []*os.File + + // SysProcAttr holds optional, operating system-specific attributes. + // Run passes it to os.StartProcess as the os.ProcAttr's Sys field. + SysProcAttr *syscall.SysProcAttr + + // Process is the underlying process, once started. + Process *os.Process + + // ProcessState contains information about an exited process, + // available after a call to Wait or Run. + ProcessState *os.ProcessState + + ctx context.Context // nil means none + lookPathErr error // LookPath error, if any. + finished bool // when Wait was called + childFiles []*os.File + closeAfterStart []io.Closer + closeAfterWait []io.Closer + goroutine []func() error + errch chan error // one send per goroutine + waitDone chan struct{} +} + +//go:linkname _cmd_closeDescriptors os/exec.(*Cmd).closeDescriptors +func _cmd_closeDescriptors(c *cmd, closers []io.Closer) + +//go:linkname _cmd_envv os/exec.(*Cmd).envv +func _cmd_envv(c *cmd) ([]string, error) + +//go:linkname _cmd_argv os/exec.(*Cmd).argv +func _cmd_argv(c *cmd) []string + +//go:linkname lookExtensions os/exec.lookExtensions +func lookExtensions(path, dir string) (string, error) + +//go:linkname dedupEnv os/exec.dedupEnv +func dedupEnv(env []string) []string + +//go:linkname addCriticalEnv os/exec.addCriticalEnv +func addCriticalEnv(env []string) []string + +//go:linkname newProcess os.newProcess +func newProcess(pid int, handle uintptr) *os.Process + +//go:linkname execEnvDefault internal/syscall/execenv.Default +func execEnvDefault(sys *syscall.SysProcAttr) (env []string, err error) + +//go:linkname createEnvBlock syscall.createEnvBlock +func createEnvBlock(envv []string) *uint16 + +//go:linkname makeCmdLine syscall.makeCmdLine +func makeCmdLine(args []string) string + +//go:linkname joinExeDirAndFName syscall.joinExeDirAndFName +func joinExeDirAndFName(dir, p string) (name string, err error) diff --git a/cmd_windows.s b/cmd_windows.s new file mode 100644 index 0000000..caddc3c --- /dev/null +++ b/cmd_windows.s @@ -0,0 +1,16 @@ +// disable default -complete option to `go tool compile` on windows + +// We are using `go:linkname` to support golang os/exec.Cmd for extended +// windows process creation (startupInfoEx), and that is not supported by its +// standard library implementation. + +// By default, the go compiler will require all functions in *.go files +// with body defined, if that's not the case, we have to enable CGO to +// enable symbol lookup. One solution to disable that compile time check +// is to add some go assembly file to your project. + +// For this project, we don't use CGO at all, and should not require users +// to set `CGO_ENABLED=1` when compiling their projects using this package. + +// By adding this empty assembly file, the go compiler will enable symbol +// lookup, so that we can have functions with no body defined in *.go files. diff --git a/doc.go b/doc.go index 3c8b324..c56777a 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,34 @@ 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 + Pty interface { + // Fd intended to resize Tty of child process in current process + FdHolder + + // 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 + Tty interface { + // Fd only intended for manual InheritSize from Pty + FdHolder + + io.ReadWriteCloser + } +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..e69de29 diff --git a/pty_unsupported.go b/pty_unsupported.go index 765523a..fb666df 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..717f1eb --- /dev/null +++ b/pty_windows.go @@ -0,0 +1,159 @@ +//go:build windows +// +build windows + +package pty + +import ( + "os" + "syscall" + "unsafe" +) + +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 = syscall.NewLazyDLL("kernel32.dll") + + // https://docs.microsoft.com/en-us/windows/console/createpseudoconsole + createPseudoConsole = kernel32DLL.NewProc("CreatePseudoConsole") + closePseudoConsole = kernel32DLL.NewProc("ClosePseudoConsole") + + deleteProcThreadAttributeList = kernel32DLL.NewProc("DeleteProcThreadAttributeList") + initializeProcThreadAttributeList = kernel32DLL.NewProc("InitializeProcThreadAttributeList") + + // https://docs.microsoft.com/en-us/windows/win32/api/processthreadsapi/nf-processthreadsapi-updateprocthreadattribute + updateProcThreadAttribute = kernel32DLL.NewProc("UpdateProcThreadAttribute") + + 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 + } + + defer func() { + if err != nil { + _ = consoleW.Close() + _ = pr.Close() + + _ = pw.Close() + _ = consoleR.Close() + } + }() + + err = createPseudoConsole.Find() + if err != nil { + return nil, nil, err + } + + var consoleHandle syscall.Handle + r1, _, err := createPseudoConsole.Call( + (windowsCoord{X: 80, Y: 30}).Pack(), // size: default 80x30 window + consoleR.Fd(), // console input + consoleW.Fd(), // console output + 0, // console flags, currently only PSEUDOCONSOLE_INHERIT_CURSOR supported + uintptr(unsafe.Pointer(&consoleHandle)), // console handler value return + ) + if r1 != 0 { + // S_OK: 0 + return nil, nil, os.NewSyscallError("CreatePseudoConsole", err) + } + + return &WindowsPty{ + handle: uintptr(consoleHandle), + r: pr, + w: pw, + consoleR: consoleR, + consoleW: consoleW, + }, &WindowsTty{ + handle: uintptr(consoleHandle), + r: consoleR, + w: consoleW, + }, nil +} + +var _ Pty = (*WindowsPty)(nil) + +type WindowsPty struct { + handle uintptr + r, w *os.File + + consoleR, consoleW *os.File +} + +func (p *WindowsPty) Fd() uintptr { + return 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) Close() error { + _ = p.r.Close() + _ = p.w.Close() + + _ = p.consoleR.Close() + _ = p.consoleW.Close() + + err := closePseudoConsole.Find() + if err != nil { + return err + } + + _, _, err = closePseudoConsole.Call(p.handle) + return err +} + +var _ Tty = (*WindowsTty)(nil) + +type WindowsTty struct { + handle uintptr + r, w *os.File +} + +func (t *WindowsTty) Fd() uintptr { + return 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() +} diff --git a/run.go b/run.go index 160001f..0ef9e0e 100644 --- a/run.go +++ b/run.go @@ -1,75 +1,14 @@ -//go:build !windows -//+build !windows - package pty import ( - "os" "os/exec" - "syscall" ) -// Start assigns a pseudo-terminal tty os.File to c.Stdin, c.Stdout, +// Start 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. +// corresponding Pty. // // Starts the process in a new session and sets the controlling terminal. -func Start(cmd *exec.Cmd) (*os.File, error) { - return StartWithSize(cmd, nil) -} - -// 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) -} - -// 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 +func Start(c *exec.Cmd) (pty Pty, err error) { + return StartWithSize(c, nil) } diff --git a/run_unix.go b/run_unix.go new file mode 100644 index 0000000..2b56db4 --- /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..48e17ef --- /dev/null +++ b/run_windows.go @@ -0,0 +1,412 @@ +//go:build windows +// +build windows + +package pty + +import ( + "errors" + "os" + "os/exec" + "runtime" + "syscall" + "unsafe" +) + +type startupInfoEx struct { + startupInfo syscall.StartupInfo + lpAttrList syscall.Handle +} + +const ( + _EXTENDED_STARTUPINFO_PRESENT = 0x00080000 + + _PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE = 0x00020016 +) + +// 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 + + err = start((*cmd)(unsafe.Pointer(c)), syscall.Handle(tty.Fd())) + if err != nil { + return nil, err + } + + return pty, err +} + +func createExtendedStartupInfo(consoleHandle syscall.Handle) (_ *startupInfoEx, err error) { + // append console handler to new process + var ( + attrBufSize uint64 + si startupInfoEx + ) + + si.startupInfo.Cb = uint32(unsafe.Sizeof(si)) + + // get size of attr list + err = initializeProcThreadAttributeList.Find() + if err != nil { + return nil, err + } + + r1, _, err := initializeProcThreadAttributeList.Call( + 0, // list ptr + 1, // list item count + 0, // dwFlags: reserved, MUST be 0 + uintptr(unsafe.Pointer(&attrBufSize)), + ) + if r1 == 0 { + // according to + // https://docs.microsoft.com/en-us/windows/win32/api/processthreadsapi/nf-processthreadsapi-initializeprocthreadattributelist + // which says: This initial call will return an error by design. This is expected behavior. + // + // so here we check the returned value of the attr buf size, if it's zero, we cannot update attribute list + if attrBufSize == 0 { + return nil, os.NewSyscallError("InitializeProcThreadAttributeList (size)", err) + } + } + + attrListBuf := make([]byte, attrBufSize) + si.lpAttrList = syscall.Handle(unsafe.Pointer(&attrListBuf[0])) + // create attr list with console handler + r1, _, err = initializeProcThreadAttributeList.Call( + uintptr(si.lpAttrList), // attr list buf + 1, // list item count + 0, // dwFlags: reserved, MUST be 0 + uintptr(unsafe.Pointer(&attrBufSize)), // size of the list + ) + if r1 == 0 { + // false + return nil, os.NewSyscallError("InitializeProcThreadAttributeList (create)", err) + } + + err = updateProcThreadAttribute.Find() + if err != nil { + return nil, err + } + + r1, _, err = updateProcThreadAttribute.Call( + uintptr(si.lpAttrList), // buf list + 0, // dwFlags: reserved, MUST be 0 + _PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE, + uintptr(consoleHandle), + unsafe.Sizeof(consoleHandle), + 0, + 0, + ) + if r1 == 0 { + // false + if deleteProcThreadAttributeList.Find() == nil { + _, _, _ = deleteProcThreadAttributeList.Call(uintptr(si.lpAttrList)) + } + return nil, os.NewSyscallError("UpdateProcThreadAttribute", err) + } + + return &si, nil +} + +// copied from os/exec.(*Cmd).Start +// start starts 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 start(c *cmd, consoleHandle syscall.Handle) error { + if c.lookPathErr != nil { + _cmd_closeDescriptors(c, c.closeAfterStart) + _cmd_closeDescriptors(c, c.closeAfterWait) + return c.lookPathErr + } + if runtime.GOOS == "windows" { + lp, err := lookExtensions(c.Path, c.Dir) + if err != nil { + _cmd_closeDescriptors(c, c.closeAfterStart) + _cmd_closeDescriptors(c, c.closeAfterWait) + return err + } + c.Path = lp + } + if c.Process != nil { + return errors.New("exec: already started") + } + if c.ctx != nil { + select { + case <-c.ctx.Done(): + _cmd_closeDescriptors(c, c.closeAfterStart) + _cmd_closeDescriptors(c, c.closeAfterWait) + return c.ctx.Err() + default: + } + } + + //c.childFiles = make([]*os.File, 0, 3+len(c.ExtraFiles)) + //type F func() (*os.File, error) + //for _, setupFd := range []F{c.stdin, c.stdout, c.stderr} { + // fd, err := setupFd() + // if err != nil { + // closeDescriptors(c, c.closeAfterStart) + // closeDescriptors(c, c.closeAfterWait) + // return err + // } + // c.childFiles = append(c.childFiles, fd) + //} + //c.childFiles = append(c.childFiles, c.ExtraFiles...) + + envv, err := _cmd_envv(c) + if err != nil { + return err + } + + c.Process, err = startProcess(c.Path, _cmd_argv(c), &os.ProcAttr{ + Dir: c.Dir, + Files: c.childFiles, + Env: addCriticalEnv(dedupEnv(envv)), + Sys: c.SysProcAttr, + }, consoleHandle) + if err != nil { + _cmd_closeDescriptors(c, c.closeAfterStart) + _cmd_closeDescriptors(c, c.closeAfterWait) + return err + } + + _cmd_closeDescriptors(c, c.closeAfterStart) + + // Don't allocate the channel unless there are goroutines to fire. + if len(c.goroutine) > 0 { + c.errch = make(chan error, len(c.goroutine)) + for _, fn := range c.goroutine { + go func(fn func() error) { + c.errch <- fn() + }(fn) + } + } + + if c.ctx != nil { + c.waitDone = make(chan struct{}) + go func() { + select { + case <-c.ctx.Done(): + _ = c.Process.Kill() + case <-c.waitDone: + } + }() + } + + return nil +} + +// copied from os.startProcess, add consoleHandle arg +func startProcess(name string, argv []string, attr *os.ProcAttr, consoleHandle syscall.Handle) (p *os.Process, err error) { + // If there is no SysProcAttr (ie. no Chroot or changed + // UID/GID), double-check existence of the directory we want + // to chdir into. We can make the error clearer this way. + if attr != nil && attr.Sys == nil && attr.Dir != "" { + if _, err := os.Stat(attr.Dir); err != nil { + pe := err.(*os.PathError) + pe.Op = "chdir" + return nil, pe + } + } + + sysattr := &syscall.ProcAttr{ + Dir: attr.Dir, + Env: attr.Env, + Sys: attr.Sys, + } + if sysattr.Env == nil { + sysattr.Env, err = execEnvDefault(sysattr.Sys) + if err != nil { + return nil, err + } + } + sysattr.Files = make([]uintptr, 0, len(attr.Files)) + for _, f := range attr.Files { + sysattr.Files = append(sysattr.Files, f.Fd()) + } + + pid, h, e := syscallStartProcess(name, argv, sysattr, consoleHandle) + + // Make sure we don't run the finalizers of attr.Files. + runtime.KeepAlive(attr) + + if e != nil { + return nil, &os.PathError{Op: "fork/exec", Path: name, Err: e} + } + + return newProcess(pid, h), nil +} + +//go:linkname zeroProcAttr syscall.zeroProcAttr +var zeroProcAttr syscall.ProcAttr + +//go:linkname zeroSysProcAttr syscall.zeroSysProcAttr +var zeroSysProcAttr syscall.SysProcAttr + +// copied from syscall.StartProcess, add consoleHandle arg +func syscallStartProcess(argv0 string, argv []string, attr *syscall.ProcAttr, consoleHandle syscall.Handle) (pid int, handle uintptr, err error) { + if len(argv0) == 0 { + return 0, 0, syscall.EWINDOWS + } + if attr == nil { + attr = &zeroProcAttr + } + sys := attr.Sys + if sys == nil { + sys = &zeroSysProcAttr + } + + //if len(attr.Files) > 3 { + // return 0, 0, syscall.EWINDOWS + //} + //if len(attr.Files) < 3 { + // return 0, 0, syscall.EINVAL + //} + + if len(attr.Dir) != 0 { + // StartProcess assumes that argv0 is relative to attr.Dir, + // because it implies Chdir(attr.Dir) before executing argv0. + // Windows CreateProcess assumes the opposite: it 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. + var err error + argv0, err = joinExeDirAndFName(attr.Dir, argv0) + if err != nil { + return 0, 0, err + } + } + argv0p, err := syscall.UTF16PtrFromString(argv0) + if err != nil { + return 0, 0, 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(argv) + } + + var argvp *uint16 + if len(cmdline) != 0 { + argvp, err = syscall.UTF16PtrFromString(cmdline) + if err != nil { + return 0, 0, err + } + } + + var dirp *uint16 + if len(attr.Dir) != 0 { + dirp, err = syscall.UTF16PtrFromString(attr.Dir) + if err != nil { + return 0, 0, 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() + + //p, _ := syscall.GetCurrentProcess() + //fd := make([]syscall.Handle, len(attr.Files)) + //for i := range attr.Files { + // if attr.Files[i] > 0 { + // err := syscall.DuplicateHandle(p, syscall.Handle(attr.Files[i]), p, &fd[i], 0, true, syscall.DUPLICATE_SAME_ACCESS) + // if err != nil { + // return 0, 0, err + // } + // defer syscall.CloseHandle(syscall.Handle(fd[i])) + // } + //} + + // replaced default syscall.StartupInfo with custom startupInfEx for console handle + //si := new(syscall.StartupInfo) + //si.Cb = uint32(unsafe.Sizeof(*si)) + si, err := createExtendedStartupInfo(consoleHandle) + if err != nil { + return 0, 0, err + } + // add finalizer for attribute list cleanup, best effort + runtime.SetFinalizer(si, func(si *startupInfoEx) { + if deleteProcThreadAttributeList.Find() == nil { + _, _, _ = deleteProcThreadAttributeList.Call(uintptr(si.lpAttrList)) + } + }) + + si.startupInfo.Flags = syscall.STARTF_USESTDHANDLES + if sys.HideWindow { + si.startupInfo.Flags |= syscall.STARTF_USESHOWWINDOW + si.startupInfo.ShowWindow = syscall.SW_HIDE + } + //si.StdInput = fd[0] + //si.StdOutput = fd[1] + //si.StdErr = fd[2] + + pi := new(syscall.ProcessInformation) + + flags := sys.CreationFlags | syscall.CREATE_UNICODE_ENVIRONMENT + + // add startupInfoEx flag + flags = flags | _EXTENDED_STARTUPINFO_PRESENT + + // ignore security attrs since both Process and Thread handles are not inheritable for conPty + if sys.Token != 0 { + err = syscall.CreateProcessAsUser(sys.Token, argv0p, argvp, nil, nil, false, flags, createEnvBlock(attr.Env), dirp, &si.startupInfo, pi) + } else { + err = syscall.CreateProcess(argv0p, argvp, nil, nil, false, flags, createEnvBlock(attr.Env), dirp, &si.startupInfo, pi) + } + if err != nil { + return 0, 0, err + } + defer syscall.CloseHandle(syscall.Handle(pi.Thread)) + + return int(pi.ProcessId), uintptr(pi.Process), nil +} diff --git a/test_crosscompile.sh b/test_crosscompile.sh index bbab6b2..3078787 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 9660a93..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,7 +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) return int(ws.Rows), int(ws.Cols), err } diff --git a/winsize_unix.go b/winsize_unix.go index f358e90..2c3c0ff 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 c4bff44..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 uint -} - -// 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..c6826ea --- /dev/null +++ b/winsize_windows.go @@ -0,0 +1,75 @@ +package pty + +import ( + "os" + "unsafe" +) + +// types from golang.org/x/sys/windows +type ( + // copy of https://pkg.go.dev/golang.org/x/sys/windows#Coord + windowsCoord struct { + X int16 + Y int16 + } + + // copy of https://pkg.go.dev/golang.org/x/sys/windows#SmallRect + windowsSmallRect struct { + Left int16 + Top int16 + Right int16 + Bottom int16 + } + + // copy of https://pkg.go.dev/golang.org/x/sys/windows#ConsoleScreenBufferInfo + 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 { + err := resizePseudoConsole.Find() + if err != nil { + return err + } + + r1, _, err := resizePseudoConsole.Call( + t.Fd(), + (windowsCoord{X: int16(ws.Cols), Y: int16(ws.Rows)}).Pack(), + ) + if r1 != 0 { + // S_OK: 0 + return os.NewSyscallError("ResizePseudoConsole", err) + } + + 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 + r1, _, err := getConsoleScreenBufferInfo.Call(t.Fd(), uintptr(unsafe.Pointer(&info))) + if r1 != 0 { + // S_OK: 0 + return nil, os.NewSyscallError("GetConsoleScreenBufferInfo", err) + } + + return &Winsize{ + Rows: uint16(info.Window.Bottom - info.Window.Top + 1), + Cols: uint16(info.Window.Right - info.Window.Left + 1), + }, nil +}