From 17728c8fe8f86bfe5e92cdfa4d2467279beb437f Mon Sep 17 00:00:00 2001 From: Martin Atkins Date: Mon, 11 Jan 2021 18:13:21 -0800 Subject: [PATCH] internal/terminal: Interrogate and initialize the terminal, if any This is a helper package that creates a very thin abstraction over terminal setup, with the main goal being to deal with all of the extra setup we need to do in order to get a UTF-8-supporting virtual terminal on a Windows system. --- go.mod | 3 +- go.sum | 4 + internal/terminal/impl_others.go | 53 ++++++++++ internal/terminal/impl_windows.go | 161 +++++++++++++++++++++++++++++ internal/terminal/panicwrap_ugh.go | 78 ++++++++++++++ internal/terminal/stream.go | 80 ++++++++++++++ internal/terminal/streams.go | 74 +++++++++++++ 7 files changed, 452 insertions(+), 1 deletion(-) create mode 100644 internal/terminal/impl_others.go create mode 100644 internal/terminal/impl_windows.go create mode 100644 internal/terminal/panicwrap_ugh.go create mode 100644 internal/terminal/stream.go create mode 100644 internal/terminal/streams.go diff --git a/go.mod b/go.mod index 61db09817..0a6bfbca0 100644 --- a/go.mod +++ b/go.mod @@ -123,7 +123,8 @@ require ( golang.org/x/mod v0.3.0 golang.org/x/net v0.0.0-20201021035429-f5854403a974 golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43 - golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f + golang.org/x/sys v0.0.0-20201119102817-f84b799fce68 + golang.org/x/term v0.0.0-20201210144234-2321bbc49cbf golang.org/x/text v0.3.3 golang.org/x/tools v0.0.0-20201028111035-eafbe7b904eb google.golang.org/api v0.34.0 diff --git a/go.sum b/go.sum index 18f43e3dd..650efa501 100644 --- a/go.sum +++ b/go.sum @@ -746,6 +746,10 @@ golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f h1:+Nyd8tzPX9R7BWHguqsrbFdRx3WQ/1ib8I44HXV5yTA= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68 h1:nxC68pudNYkKU6jWhgrqdreuFiOQWj1Fs7T3VrH4Pjw= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/term v0.0.0-20201210144234-2321bbc49cbf h1:MZ2shdL+ZM/XzY3ZGOnh4Nlpnxz5GSOhOmtHo3iPU6M= +golang.org/x/term v0.0.0-20201210144234-2321bbc49cbf/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.0.0-20160726164857-2910a502d2bf/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= diff --git a/internal/terminal/impl_others.go b/internal/terminal/impl_others.go new file mode 100644 index 000000000..fc819aee6 --- /dev/null +++ b/internal/terminal/impl_others.go @@ -0,0 +1,53 @@ +// +build !windows + +package terminal + +import ( + "os" + + "golang.org/x/term" +) + +// This is the implementation for all operating systems except Windows, where +// we don't expect to need to do any special initialization to get a working +// Virtual Terminal. +// +// For this implementation we just delegate everything upstream to +// golang.org/x/term, since it already has a variety of different +// implementations for quirks of more esoteric operating systems like plan9, +// and will hopefully grow to include others as Go is ported to other platforms +// in future. +// +// For operating systems that golang.org/x/term doesn't support either, it +// defaults to indicating that nothing is a terminal and returns an error when +// asked for a size, which we'll handle below. + +func configureOutputHandle(f *os.File) (*OutputStream, error) { + return &OutputStream{ + File: f, + isTerminal: isTerminalGolangXTerm, + getColumns: getColumnsGolangXTerm, + }, nil +} + +func configureInputHandle(f *os.File) (*InputStream, error) { + return &InputStream{ + File: f, + isTerminal: isTerminalGolangXTerm, + }, nil +} + +func isTerminalGolangXTerm(f *os.File) bool { + return term.IsTerminal(int(f.Fd())) +} + +func getColumnsGolangXTerm(f *os.File) int { + width, _, err := term.GetSize(int(f.Fd())) + if err != nil { + // Suggests that it's either not a terminal at all or that we're on + // a platform that golang.org/x/term doesn't support. In both cases + // we'll just return the placeholder default value. + return defaultColumns + } + return width +} diff --git a/internal/terminal/impl_windows.go b/internal/terminal/impl_windows.go new file mode 100644 index 000000000..f6aaa78b9 --- /dev/null +++ b/internal/terminal/impl_windows.go @@ -0,0 +1,161 @@ +// +build windows + +package terminal + +import ( + "fmt" + "os" + "syscall" + + "golang.org/x/sys/windows" + + // We're continuing to use this third-party library on Windows because it + // has the additional IsCygwinTerminal function, which includes some useful + // heuristics for recognizing when a pipe seems to be connected to a + // legacy terminal emulator on Windows versions that lack true pty support. + // We now use golang.org/x/term's functionality on other platforms. + isatty "github.com/mattn/go-isatty" +) + +func configureOutputHandle(f *os.File) (*OutputStream, error) { + ret := &OutputStream{ + File: f, + } + + if fd := f.Fd(); isatty.IsTerminal(fd) { + // We have a few things to deal with here: + // - Activating UTF-8 output support (mandatory) + // - Activating virtual terminal support (optional) + // These will not succeed on Windows 8 or early versions of Windows 10. + + // UTF-8 support means switching the console "code page" to CP_UTF8. + // Notice that this doesn't take the specific file descriptor, because + // the console is just ambiently associated with our process. + err := SetConsoleOutputCP(CP_UTF8) + if err != nil { + return nil, fmt.Errorf("failed to set the console to UTF-8 mode; you may need to use a newer version of Windows: %s", err) + } + + // If the console also allows us to turn on + // ENABLE_VIRTUAL_TERMINAL_PROCESSING then we can potentially use VT + // output, although the methods of Settings will make the final + // determination on that because we might have some handles pointing at + // terminals and other handles pointing at files/pipes. + ret.getColumns = getColumnsWindowsConsole + var mode uint32 + err = windows.GetConsoleMode(windows.Handle(fd), &mode) + if err != nil { + return ret, nil // We'll treat this as success but without VT support + } + mode |= windows.ENABLE_VIRTUAL_TERMINAL_PROCESSING + err = windows.SetConsoleMode(windows.Handle(fd), mode) + if err != nil { + return ret, nil // We'll treat this as success but without VT support + } + + // If we get here then we've successfully turned on VT processing, so + // we can return an OutputStream that answers true when asked if it + // is a Terminal. + ret.isTerminal = staticTrue + return ret, nil + + } else if isatty.IsCygwinTerminal(fd) { + // Cygwin terminals -- and other VT100 "fakers" for older versions of + // Windows -- are not really terminals in the usual sense, but rather + // are pipes between the child process (Terraform) and the terminal + // emulator. isatty.IsCygwinTerminal uses some heuristics to + // distinguish those pipes from other pipes we might see if the user + // were, for example, using the | operator on the command line. + // If we get in here then we'll assume that we can send VT100 sequences + // to this stream, even though it isn't a terminal in the usual sense. + + ret.isTerminal = staticTrue + // TODO: Is it possible to detect the width of these fake terminals? + return ret, nil + } + + // If we fall out here then we have a non-terminal filehandle, so we'll + // just accept all of the default OutputStream behaviors + return ret, nil +} + +func configureInputHandle(f *os.File) (*InputStream, error) { + ret := &InputStream{ + File: f, + } + + if fd := f.Fd(); isatty.IsTerminal(fd) { + // We have to activate UTF-8 input, or else we fail. This will not + // succeed on Windows 8 or early versions of Windows 10. + // Notice that this doesn't take the specific file descriptor, because + // the console is just ambiently associated with our process. + err := SetConsoleCP(CP_UTF8) + if err != nil { + return nil, fmt.Errorf("failed to set the console to UTF-8 mode; you may need to use a newer version of Windows: %s", err) + } + ret.isTerminal = staticTrue + return ret, nil + } else if isatty.IsCygwinTerminal(fd) { + // As with the output handles above, we'll use isatty's heuristic to + // pretend that a pipe from mintty or a similar userspace terminal + // emulator is actually a terminal. + ret.isTerminal = staticTrue + return ret, nil + } + + // If we fall out here then we have a non-terminal filehandle, so we'll + // just accept all of the default InputStream behaviors + return ret, nil +} + +func getColumnsWindowsConsole(f *os.File) int { + // We'll just unconditionally ask the given file for its console buffer + // info here, and let it fail if the file isn't actually a console. + // (In practice, the init functions above only hook up this function + // if the handle looks like a console, so this should succeed.) + var info windows.ConsoleScreenBufferInfo + err := windows.GetConsoleScreenBufferInfo(windows.Handle(f.Fd()), &info) + if err != nil { + return defaultColumns + } + return int(info.Size.X) +} + +// Unfortunately not all of the Windows kernel functions we need are in +// x/sys/windows at the time of writing, so we need to call some of them +// directly. (If you're maintaining this in future and have the capacity to +// test it well, consider checking if these functions have been added upstream +// yet and switch to their wrapper stubs if so. +var modkernel32 = windows.NewLazySystemDLL("kernel32.dll") +var procSetConsoleCP = modkernel32.NewProc("SetConsoleCP") +var procSetConsoleOutputCP = modkernel32.NewProc("SetConsoleOutputCP") + +const CP_UTF8 = 65001 + +// (These are written in the style of the stubs in x/sys/windows, which is +// a little non-idiomatic just due to the awkwardness of the low-level syscall +// interface.) + +func SetConsoleCP(codepageID uint32) (err error) { + r1, _, e1 := syscall.Syscall(procSetConsoleCP.Addr(), 1, uintptr(codepageID), 0, 0) + if r1 == 0 { + err = e1 + } + return +} + +func SetConsoleOutputCP(codepageID uint32) (err error) { + r1, _, e1 := syscall.Syscall(procSetConsoleOutputCP.Addr(), 1, uintptr(codepageID), 0, 0) + if r1 == 0 { + err = e1 + } + return +} + +func staticTrue(f *os.File) bool { + return true +} + +func staticFalse(f *os.File) bool { + return false +} diff --git a/internal/terminal/panicwrap_ugh.go b/internal/terminal/panicwrap_ugh.go new file mode 100644 index 000000000..b17165b2c --- /dev/null +++ b/internal/terminal/panicwrap_ugh.go @@ -0,0 +1,78 @@ +package terminal + +import "os" + +// This file has some annoying nonsense to, yet again, work around the +// panicwrap hack. +// +// Specifically, typically when we're running Terraform the stderr handle is +// not directly connected to the terminal but is instead a pipe into a parent +// process gathering up the output just in case a panic message appears. +// However, this package needs to know whether the _real_ stderr is connected +// to a terminal and what its width is. +// +// To work around that, we'll first initialize the terminal in the parent +// process, and then capture information about stderr into an environment +// variable so we can pass it down to the child process. The child process +// will then use the environment variable to pretend that the panicwrap pipe +// has the same characteristics as the terminal that it's indirectly writing +// to. +// +// This file has some helpers for implementing that awkward handshake, but the +// handshake itself is in package main, interspersed with all of the other +// panicwrap machinery. +// +// You might think that the code in helper/wrappedstreams could avoid this +// problem, but that package is broken on Windows: it always fails to recover +// the real stderr, and it also gets an incorrect result if the user was +// redirecting or piping stdout/stdin. So... we have this hack instead, which +// gets a correct result even on Windows and even with I/O redirection. + +// StateForAfterPanicWrap is part of the workaround for panicwrap that +// captures some characteristics of stderr that the caller can pass to the +// panicwrap child process somehow and then use ReinitInsidePanicWrap. +func (s *Streams) StateForAfterPanicWrap() *PrePanicwrapState { + return &PrePanicwrapState{ + StderrIsTerminal: s.Stderr.IsTerminal(), + StderrWidth: s.Stderr.Columns(), + } +} + +// ReinitInsidePanicwrap is part of the workaround for panicwrap that +// produces a Streams containing a potentially-lying Stderr that might +// claim to be a terminal even if it's actually a pipe connected to the +// parent process. +// +// That's an okay lie in practice because the parent process will copy any +// data it recieves via that pipe verbatim to the real stderr anyway. (The +// original call to Init in the parent process should've already done any +// necessary modesetting on the Stderr terminal, if any.) +// +// The state argument can be nil if we're not running in panicwrap mode, +// in which case this function behaves exactly the same as Init. +func ReinitInsidePanicwrap(state *PrePanicwrapState) (*Streams, error) { + ret, err := Init() + if err != nil { + return ret, err + } + if state != nil { + // A lying stderr, then. + ret.Stderr = &OutputStream{ + File: ret.Stderr.File, + isTerminal: func(f *os.File) bool { + return state.StderrIsTerminal + }, + getColumns: func(f *os.File) int { + return state.StderrWidth + }, + } + } + return ret, nil +} + +// PrePanicwrapState is a horrible thing we use to work around panicwrap, +// related to both Streams.StateForAfterPanicWrap and ReinitInsidePanicwrap. +type PrePanicwrapState struct { + StderrIsTerminal bool + StderrWidth int +} diff --git a/internal/terminal/stream.go b/internal/terminal/stream.go new file mode 100644 index 000000000..6d40e1b18 --- /dev/null +++ b/internal/terminal/stream.go @@ -0,0 +1,80 @@ +package terminal + +import ( + "os" +) + +const defaultColumns int = 78 +const defaultIsTerminal bool = false + +// OutputStream represents an output stream that might or might not be connected +// to a terminal. +// +// There are typically two instances of this: one representing stdout and one +// representing stderr. +type OutputStream struct { + File *os.File + + // Interacting with a terminal is typically platform-specific, so we + // factor out these into virtual functions, although we have default + // behaviors suitable for non-Terminal output if any of these isn't + // set. (We're using function pointers rather than interfaces for this + // because it allows us to mix both normal methods and virtual methods + // on the same type, without a bunch of extra complexity.) + isTerminal func(*os.File) bool + getColumns func(*os.File) int +} + +// Columns returns a number of character cell columns that we expect will +// fill the width of the terminal that stdout is connected to, or a reasonable +// placeholder value of 78 if the output doesn't seem to be a terminal. +// +// This is a best-effort sort of function which may give an inaccurate result +// in various cases. For example, callers storing the result will not react +// to subsequent changes in the terminal width, and indeed this function itself +// may not be able to either, depending on the constraints of the current +// execution context. +func (s *OutputStream) Columns() int { + if s.getColumns == nil { + return defaultColumns + } + return s.getColumns(s.File) +} + +// IsTerminal returns true if we expect that the stream is connected to a +// terminal which supports VT100-style formatting and cursor control sequences. +func (s *OutputStream) IsTerminal() bool { + if s.isTerminal == nil { + return defaultIsTerminal + } + return s.isTerminal(s.File) +} + +// InputStream represents an input stream that might or might not be a terminal. +// +// There is typically only one instance of this type, representing stdin. +type InputStream struct { + File *os.File + + // Interacting with a terminal is typically platform-specific, so we + // factor out these into virtual functions, although we have default + // behaviors suitable for non-Terminal output if any of these isn't + // set. (We're using function pointers rather than interfaces for this + // because it allows us to mix both normal methods and virtual methods + // on the same type, without a bunch of extra complexity.) + isTerminal func(*os.File) bool +} + +// IsTerminal returns true if we expect that the stream is connected to a +// terminal which can support interactive input. +// +// If this returns false, callers might prefer to skip elaborate input prompt +// functionality like tab completion and instead just treat the input as a +// raw byte stream, or perhaps skip prompting for input at all depending on the +// situation. +func (s *InputStream) IsTerminal() bool { + if s.isTerminal == nil { + return defaultIsTerminal + } + return s.isTerminal(s.File) +} diff --git a/internal/terminal/streams.go b/internal/terminal/streams.go new file mode 100644 index 000000000..b473e6eda --- /dev/null +++ b/internal/terminal/streams.go @@ -0,0 +1,74 @@ +// Package terminal encapsulates some platform-specific logic for detecting +// if we're running in a terminal and, if so, properly configuring that +// terminal to meet the assumptions that the rest of Terraform makes. +// +// Specifically, Terraform requires a Terminal which supports virtual terminal +// sequences and which accepts UTF-8-encoded text. +// +// This is an abstraction only over the platform-specific detection of and +// possibly initialization of terminals. It's not intended to provide +// higher-level abstractions of the sort provided by packages like termcap or +// curses; ultimately we just assume that terminals are "standard" VT100-like +// terminals and use a subset of control codes that works across the various +// platforms we support. Our approximate target is "xterm-compatible" +// virtual terminals. +package terminal + +import ( + "os" +) + +// Streams represents a collection of three streams that each may or may not +// be connected to a terminal. +// +// If a stream is connected to a terminal then there are more possibilities +// available, such as detecting the current terminal width. If we're connected +// to something else, such as a pipe or a file on disk, the stream will +// typically provide placeholder values or do-nothing stubs for +// terminal-requiring operatons. +// +// Note that it's possible for only a subset of the streams to be connected +// to a terminal. For example, this happens if the user runs Terraform with +// I/O redirection where Stdout might refer to a regular disk file while Stderr +// refers to a terminal, or various other similar combinations. +type Streams struct { + Stdout *OutputStream + Stderr *OutputStream + Stdin *InputStream +} + +// Init tries to initialize a terminal, if Terraform is running in one, and +// returns an object describing what it was able to set up. +// +// An error for this function indicates that the current execution context +// can't meet Terraform's assumptions. For example, on Windows Init will return +// an error if Terraform is running in a Windows Console that refuses to +// activate UTF-8 mode, which can happen if we're running on an unsupported old +// version of Windows. +// +// Note that the success of this function doesn't mean that we're actually +// running in a terminal. It could also represent successfully detecting that +// one or more of the input/output streams is not a terminal. +func Init() (*Streams, error) { + // These configure* functions are platform-specific functions in other + // files that use //+build constraints to vary based on target OS. + + stderr, err := configureOutputHandle(os.Stderr) + if err != nil { + return nil, err + } + stdout, err := configureOutputHandle(os.Stdout) + if err != nil { + return nil, err + } + stdin, err := configureInputHandle(os.Stdin) + if err != nil { + return nil, err + } + + return &Streams{ + Stdout: stdout, + Stderr: stderr, + Stdin: stdin, + }, nil +}