232 lines
5.9 KiB
Go
232 lines
5.9 KiB
Go
// Copyright © 2020 The Homeport Team
|
|
//
|
|
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
// of this software and associated documentation files (the "Software"), to deal
|
|
// in the Software without restriction, including without limitation the rights
|
|
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
// copies of the Software, and to permit persons to whom the Software is
|
|
// furnished to do so, subject to the following conditions:
|
|
//
|
|
// The above copyright notice and this permission notice shall be included in
|
|
// all copies or substantial portions of the Software.
|
|
//
|
|
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
// THE SOFTWARE.
|
|
|
|
package ptexec
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"os/signal"
|
|
"strings"
|
|
"syscall"
|
|
|
|
"github.com/creack/pty"
|
|
"github.com/mattn/go-isatty"
|
|
"golang.org/x/term"
|
|
)
|
|
|
|
// PseudoTerminal defines the setup for a command to be run in a pseudo
|
|
// terminal, e.g. terminal size, or output settings
|
|
type PseudoTerminal struct {
|
|
name string
|
|
args []string
|
|
|
|
shell string
|
|
|
|
cols uint16
|
|
rows uint16
|
|
resize bool
|
|
|
|
stdout io.Writer
|
|
}
|
|
|
|
// New creates a new pseudo terminal builder
|
|
func New() *PseudoTerminal {
|
|
return &PseudoTerminal{
|
|
shell: "/bin/sh",
|
|
resize: true,
|
|
stdout: os.Stdout,
|
|
}
|
|
}
|
|
|
|
// Cols sets the width/columns for the pseudo terminal
|
|
func (c *PseudoTerminal) Cols(cols uint16) *PseudoTerminal {
|
|
c.cols = cols
|
|
return c
|
|
}
|
|
|
|
// Rows sets the lines/rows for the pseudo terminal
|
|
func (c *PseudoTerminal) Rows(rows uint16) *PseudoTerminal {
|
|
c.rows = rows
|
|
return c
|
|
}
|
|
|
|
// Stdout sets the writer to be used for the standard output
|
|
func (c *PseudoTerminal) Stdout(stdout io.Writer) *PseudoTerminal {
|
|
c.stdout = stdout
|
|
return c
|
|
}
|
|
|
|
// Command sets the command and arguments to be used
|
|
func (c *PseudoTerminal) Command(name string, args ...string) *PseudoTerminal {
|
|
c.name = name
|
|
c.args = args
|
|
return c
|
|
}
|
|
|
|
// Run runs the provided command/script with the given arguments in a pseudo
|
|
// terminal (PTY) so that the behavior is the same if it would be executed
|
|
// in a terminal
|
|
func (c *PseudoTerminal) Run() ([]byte, error) {
|
|
if c.name == "" {
|
|
return nil, fmt.Errorf("no command specified")
|
|
}
|
|
|
|
// Convenience hack in case command contains a space, for example in case
|
|
// typical construct like "foo | grep" are used.
|
|
if strings.Contains(c.name, " ") {
|
|
c.args = []string{
|
|
"-c",
|
|
strings.Join(append(
|
|
[]string{c.name},
|
|
c.args...,
|
|
), " "),
|
|
}
|
|
c.name = c.shell
|
|
}
|
|
|
|
// Set RAW mode for Stdin
|
|
if isTerminal(os.Stdin) {
|
|
oldState, rawErr := term.MakeRaw(int(os.Stdin.Fd()))
|
|
if rawErr != nil {
|
|
return nil, fmt.Errorf("failed to enable RAW mode for Stdin: %w", rawErr)
|
|
}
|
|
|
|
// And make sure to restore the original mode eventually
|
|
defer func() { _ = term.Restore(int(os.Stdin.Fd()), oldState) }()
|
|
}
|
|
|
|
// collect all errors along the way
|
|
var errors = []error{}
|
|
|
|
// #nosec G204 -- since this is exactly what we want, arbitrary commands
|
|
pt, err := c.pseudoTerminal(exec.Command(c.name, c.args...))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Support terminal resizing
|
|
if c.resize && isTerminal(os.Stdin) {
|
|
ch := make(chan os.Signal, 1)
|
|
signal.Notify(ch, syscall.SIGWINCH)
|
|
go func() {
|
|
for range ch {
|
|
if ptyErr := pty.InheritSize(os.Stdin, pt); ptyErr != nil {
|
|
errors = append(errors, fmt.Errorf("error resizing PTY: %w", ptyErr))
|
|
}
|
|
}
|
|
}()
|
|
|
|
ch <- syscall.SIGWINCH
|
|
defer func() {
|
|
signal.Stop(ch)
|
|
close(ch)
|
|
}()
|
|
}
|
|
|
|
go func() {
|
|
defer func() { _ = pt.Close() }()
|
|
_, copyErr := io.Copy(pt, os.Stdin)
|
|
if copyErr != nil {
|
|
errors = append(errors, copyErr)
|
|
}
|
|
}()
|
|
|
|
var buf bytes.Buffer
|
|
if err = copy(io.MultiWriter(c.stdout, &buf), pt); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if len(errors) > 0 {
|
|
fmt.Fprintf(os.Stderr, "issues in background tasks:\n")
|
|
for _, err := range errors {
|
|
fmt.Fprintf(os.Stderr, "- %v\n", err.Error())
|
|
}
|
|
}
|
|
|
|
return buf.Bytes(), nil
|
|
}
|
|
|
|
func (c *PseudoTerminal) pseudoTerminal(cmd *exec.Cmd) (*os.File, error) {
|
|
if c.cols == 0 && c.rows == 0 {
|
|
return pty.Start(cmd)
|
|
}
|
|
|
|
size, err := pty.GetsizeFull(os.Stdout)
|
|
if err != nil {
|
|
// Obtaining terminal size is prone to error in CI systems, e.g. in
|
|
// GitHub Action setup or similar, so only fail if CI is not set
|
|
if !isCI() {
|
|
return nil, fmt.Errorf("failed to get size: %w", err)
|
|
}
|
|
|
|
// For CI systems, assume a reasonable default even if the terminal
|
|
// size cannot be obtained through ioctl
|
|
size = &pty.Winsize{Rows: 25, Cols: 80}
|
|
}
|
|
|
|
// Overwrite rows if fixed value is configured
|
|
if c.rows != 0 {
|
|
size.Rows = c.rows
|
|
}
|
|
|
|
// Overwrite columns if fixed value is configured
|
|
if c.cols != 0 {
|
|
size.Cols = c.cols
|
|
}
|
|
|
|
// With fixed rows/cols, terminal resizing support is not useful
|
|
c.resize = false
|
|
|
|
return pty.StartWithSize(cmd, size)
|
|
}
|
|
|
|
func copy(dst io.Writer, src io.Reader) error {
|
|
_, err := io.Copy(dst, src)
|
|
if err != nil {
|
|
switch terr := err.(type) { //nolint:gocritic
|
|
case *os.PathError:
|
|
// Workaround for issue https://github.com/creack/pty/issues/100
|
|
// where on Linux systems it can happen that the pseudo terminal
|
|
// process finishes while termshot is trying to read. Assuming
|
|
// that the content is already read, this error is treated the
|
|
// same as if it would be an EOF.
|
|
if terr.Op == "read" && terr.Path == "/dev/ptmx" {
|
|
return nil
|
|
}
|
|
}
|
|
}
|
|
|
|
return err
|
|
}
|
|
|
|
func isTerminal(f *os.File) bool {
|
|
return isatty.IsTerminal(f.Fd()) ||
|
|
isatty.IsCygwinTerminal(f.Fd())
|
|
}
|
|
|
|
func isCI() bool {
|
|
ci, ok := os.LookupEnv("CI")
|
|
return ok && ci == "true"
|
|
}
|