From 83dce0c226fda64aaa8e11e8717057555dfef415 Mon Sep 17 00:00:00 2001 From: Brian McGee Date: Wed, 2 Oct 2024 10:26:17 +0100 Subject: [PATCH 1/2] feat: move tui code to tui package Makes PluginTerminalUI available for sops (and other third parties) so they can more easily support plugins. --- cmd/age/age.go | 85 +++++++++++++++++++++-------------------- cmd/age/age_test.go | 5 ++- cmd/age/parse.go | 13 ++++--- {cmd/age => tui}/tui.go | 71 +++++++++++++++++----------------- 4 files changed, 89 insertions(+), 85 deletions(-) rename {cmd/age => tui}/tui.go (75%) diff --git a/cmd/age/age.go b/cmd/age/age.go index e5d17e2b..a4397350 100644 --- a/cmd/age/age.go +++ b/cmd/age/age.go @@ -7,6 +7,7 @@ package main import ( "bufio" "bytes" + "filippo.io/age/tui" "flag" "fmt" "io" @@ -104,7 +105,7 @@ func main() { if len(os.Args) == 1 { flag.Usage() - exit(1) + tui.Exit(1) } var ( @@ -180,47 +181,47 @@ func main() { hints = append(hints, "only a single input file may be specified at a time") } - errorWithHint("too many INPUT arguments: "+quotedArgs, hints...) + tui.ErrorWithHint("too many INPUT arguments: "+quotedArgs, hints...) } switch { case decryptFlag: if encryptFlag { - errorf("-e/--encrypt can't be used with -d/--decrypt") + tui.Errorf("-e/--encrypt can't be used with -d/--decrypt") } if armorFlag { - errorWithHint("-a/--armor can't be used with -d/--decrypt", + tui.ErrorWithHint("-a/--armor can't be used with -d/--decrypt", "note that armored files are detected automatically") } if passFlag { - errorWithHint("-p/--passphrase can't be used with -d/--decrypt", + tui.ErrorWithHint("-p/--passphrase can't be used with -d/--decrypt", "note that password protected files are detected automatically") } if len(recipientFlags) > 0 { - errorWithHint("-r/--recipient can't be used with -d/--decrypt", + tui.ErrorWithHint("-r/--recipient can't be used with -d/--decrypt", "did you mean to use -i/--identity to specify a private key?") } if len(recipientsFileFlags) > 0 { - errorWithHint("-R/--recipients-file can't be used with -d/--decrypt", + tui.ErrorWithHint("-R/--recipients-file can't be used with -d/--decrypt", "did you mean to use -i/--identity to specify a private key?") } default: // encrypt if len(identityFlags) > 0 && !encryptFlag { - errorWithHint("-i/--identity and -j can't be used in encryption mode unless symmetric encryption is explicitly selected with -e/--encrypt", + tui.ErrorWithHint("-i/--identity and -j can't be used in encryption mode unless symmetric encryption is explicitly selected with -e/--encrypt", "did you forget to specify -d/--decrypt?") } if len(recipientFlags)+len(recipientsFileFlags)+len(identityFlags) == 0 && !passFlag { - errorWithHint("missing recipients", + tui.ErrorWithHint("missing recipients", "did you forget to specify -r/--recipient, -R/--recipients-file or -p/--passphrase?") } if len(recipientFlags) > 0 && passFlag { - errorf("-p/--passphrase can't be combined with -r/--recipient") + tui.Errorf("-p/--passphrase can't be combined with -r/--recipient") } if len(recipientsFileFlags) > 0 && passFlag { - errorf("-p/--passphrase can't be combined with -R/--recipients-file") + tui.Errorf("-p/--passphrase can't be combined with -R/--recipients-file") } if len(identityFlags) > 0 && passFlag { - errorf("-p/--passphrase can't be combined with -i/--identity and -j") + tui.Errorf("-p/--passphrase can't be combined with -i/--identity and -j") } } @@ -241,7 +242,7 @@ func main() { inUseFiles = append(inUseFiles, absPath(name)) f, err := os.Open(name) if err != nil { - errorf("failed to open input file %q: %v", name, err) + tui.Errorf("failed to open input file %q: %v", name, err) } defer f.Close() in = f @@ -251,9 +252,9 @@ func main() { // If the input comes from a TTY, assume it's armored, and buffer up // to the END line (or EOF/EOT) so that a password prompt or the // output don't get in the way of typing the input. See Issue 364. - buf, err := bufferTerminalInput(in) + buf, err := tui.BufferTerminalInput(in) if err != nil { - errorf("failed to buffer terminal input: %v", err) + tui.Errorf("failed to buffer terminal input: %v", err) } in = buf } @@ -261,13 +262,13 @@ func main() { if name := outFlag; name != "" && name != "-" { for _, f := range inUseFiles { if f == absPath(name) { - errorf("input and output file are the same: %q", name) + tui.Errorf("input and output file are the same: %q", name) } } f := newLazyOpener(name) defer func() { if err := f.Close(); err != nil { - errorf("failed to close output file %q: %v", name, err) + tui.Errorf("failed to close output file %q: %v", name, err) } }() out = f @@ -278,7 +279,7 @@ func main() { } else if !armorFlag { // If the output wouldn't be armored, refuse to send binary to // the terminal unless explicitly requested with "-o -". - errorWithHint("refusing to output binary to the terminal", + tui.ErrorWithHint("refusing to output binary to the terminal", "did you mean to use -a/--armor?", `force anyway with "-o -"`) } @@ -305,7 +306,7 @@ func main() { } func passphrasePromptForEncryption() (string, error) { - pass, err := readSecret("Enter passphrase (leave empty to autogenerate a secure one):") + pass, err := tui.ReadSecret("Enter passphrase (leave empty to autogenerate a secure one):") if err != nil { return "", fmt.Errorf("could not read passphrase: %v", err) } @@ -316,12 +317,12 @@ func passphrasePromptForEncryption() (string, error) { words = append(words, randomWord()) } p = strings.Join(words, "-") - err := printfToTerminal("using autogenerated passphrase %q", p) + err := tui.PrintfToTerminal("using autogenerated passphrase %q", p) if err != nil { return "", fmt.Errorf("could not print passphrase: %v", err) } } else { - confirm, err := readSecret("Confirm passphrase:") + confirm, err := tui.ReadSecret("Confirm passphrase:") if err != nil { return "", fmt.Errorf("could not read passphrase: %v", err) } @@ -337,19 +338,19 @@ func encryptNotPass(recs, files []string, identities identityFlags, in io.Reader for _, arg := range recs { r, err := parseRecipient(arg) if err, ok := err.(gitHubRecipientError); ok { - errorWithHint(err.Error(), "instead, use recipient files like", + tui.ErrorWithHint(err.Error(), "instead, use recipient files like", " curl -O https://github.com/"+err.username+".keys", " age -R "+err.username+".keys") } if err != nil { - errorf("%v", err) + tui.Errorf("%v", err) } recipients = append(recipients, r) } for _, name := range files { recs, err := parseRecipientsFile(name) if err != nil { - errorf("failed to parse recipient file %q: %v", name, err) + tui.Errorf("failed to parse recipient file %q: %v", name, err) } recipients = append(recipients, recs...) } @@ -358,17 +359,17 @@ func encryptNotPass(recs, files []string, identities identityFlags, in io.Reader case "i": ids, err := parseIdentitiesFile(f.Value) if err != nil { - errorf("reading %q: %v", f.Value, err) + tui.Errorf("reading %q: %v", f.Value, err) } r, err := identitiesToRecipients(ids) if err != nil { - errorf("internal error processing %q: %v", f.Value, err) + tui.Errorf("internal error processing %q: %v", f.Value, err) } recipients = append(recipients, r...) case "j": - id, err := plugin.NewIdentityWithoutData(f.Value, pluginTerminalUI) + id, err := plugin.NewIdentityWithoutData(f.Value, tui.PluginTerminalUI) if err != nil { - errorf("initializing %q: %v", f.Value, err) + tui.Errorf("initializing %q: %v", f.Value, err) } recipients = append(recipients, id.Recipient()) } @@ -379,12 +380,12 @@ func encryptNotPass(recs, files []string, identities identityFlags, in io.Reader func encryptPass(in io.Reader, out io.Writer, armor bool) { pass, err := passphrasePromptForEncryption() if err != nil { - errorf("%v", err) + tui.Errorf("%v", err) } r, err := age.NewScryptRecipient(pass) if err != nil { - errorf("%v", err) + tui.Errorf("%v", err) } testOnlyConfigureScryptIdentity(r) encrypt([]age.Recipient{r}, in, out, armor) @@ -397,20 +398,20 @@ func encrypt(recipients []age.Recipient, in io.Reader, out io.Writer, withArmor a := armor.NewWriter(out) defer func() { if err := a.Close(); err != nil { - errorf("%v", err) + tui.Errorf("%v", err) } }() out = a } w, err := age.Encrypt(out, recipients...) if err != nil { - errorf("%v", err) + tui.Errorf("%v", err) } if _, err := io.Copy(w, in); err != nil { - errorf("%v", err) + tui.Errorf("%v", err) } if err := w.Close(); err != nil { - errorf("%v", err) + tui.Errorf("%v", err) } } @@ -426,7 +427,7 @@ func (rejectScryptIdentity) Unwrap(stanzas []*age.Stanza) ([]byte, error) { if len(stanzas) != 1 || stanzas[0].Type != "scrypt" { return nil, age.ErrIncorrectIdentity } - errorWithHint("file is passphrase-encrypted but identities were specified with -i/--identity or -j", + tui.ErrorWithHint("file is passphrase-encrypted but identities were specified with -i/--identity or -j", "remove all -i/--identity/-j flags to decrypt passphrase-encrypted files") panic("unreachable") } @@ -439,13 +440,13 @@ func decryptNotPass(flags identityFlags, in io.Reader, out io.Writer) { case "i": ids, err := parseIdentitiesFile(f.Value) if err != nil { - errorf("reading %q: %v", f.Value, err) + tui.Errorf("reading %q: %v", f.Value, err) } identities = append(identities, ids...) case "j": - id, err := plugin.NewIdentityWithoutData(f.Value, pluginTerminalUI) + id, err := plugin.NewIdentityWithoutData(f.Value, tui.PluginTerminalUI) if err != nil { - errorf("initializing %q: %v", f.Value, err) + tui.Errorf("initializing %q: %v", f.Value, err) } identities = append(identities, id) } @@ -468,7 +469,7 @@ func decrypt(identities []age.Identity, in io.Reader, out io.Writer) { rr := bufio.NewReader(in) if intro, _ := rr.Peek(len(crlfMangledIntro)); string(intro) == crlfMangledIntro || string(intro) == utf16MangledIntro { - errorWithHint("invalid header intro", + tui.ErrorWithHint("invalid header intro", "it looks like this file was corrupted by PowerShell redirection", "consider using -o or -a to encrypt files in PowerShell") } @@ -481,16 +482,16 @@ func decrypt(identities []age.Identity, in io.Reader, out io.Writer) { r, err := age.Decrypt(in, identities...) if err != nil { - errorf("%v", err) + tui.Errorf("%v", err) } out.Write(nil) // trigger the lazyOpener even if r is empty if _, err := io.Copy(out, r); err != nil { - errorf("%v", err) + tui.Errorf("%v", err) } } func passphrasePromptForDecryption() (string, error) { - pass, err := readSecret("Enter passphrase:") + pass, err := tui.ReadSecret("Enter passphrase:") if err != nil { return "", fmt.Errorf("could not read passphrase: %v", err) } diff --git a/cmd/age/age_test.go b/cmd/age/age_test.go index 92918299..757bbf7a 100644 --- a/cmd/age/age_test.go +++ b/cmd/age/age_test.go @@ -6,6 +6,7 @@ package main import ( "bufio" + "filippo.io/age/tui" "os" "testing" @@ -16,9 +17,9 @@ import ( func TestMain(m *testing.M) { os.Exit(testscript.RunMain(m, map[string]func() int{ "age": func() (exitCode int) { - testOnlyPanicInsteadOfExit = true + tui.TestOnlyPanicInsteadOfExit = true defer func() { - if testOnlyDidExit { + if tui.TestOnlyDidExit { exitCode = recover().(int) } }() diff --git a/cmd/age/parse.go b/cmd/age/parse.go index 4a59e7a4..1a1e0434 100644 --- a/cmd/age/parse.go +++ b/cmd/age/parse.go @@ -7,6 +7,7 @@ package main import ( "bufio" "encoding/base64" + "filippo.io/age/tui" "fmt" "io" "os" @@ -31,7 +32,7 @@ func (gitHubRecipientError) Error() string { func parseRecipient(arg string) (age.Recipient, error) { switch { case strings.HasPrefix(arg, "age1") && strings.Count(arg, "1") > 1: - return plugin.NewRecipient(arg, pluginTerminalUI) + return plugin.NewRecipient(arg, tui.PluginTerminalUI) case strings.HasPrefix(arg, "age1"): return age.ParseX25519Recipient(arg) case strings.HasPrefix(arg, "ssh-"): @@ -79,7 +80,7 @@ func parseRecipientsFile(name string) ([]age.Recipient, error) { if err != nil { if t, ok := sshKeyType(line); ok { // Skip unsupported but valid SSH public keys with a warning. - warningf("recipients file %q: ignoring unsupported SSH key of type %q at line %d", name, t, n) + tui.Warningf("recipients file %q: ignoring unsupported SSH key of type %q at line %d", name, t, n) continue } // Hide the error since it might unintentionally leak the contents @@ -162,14 +163,14 @@ func parseIdentitiesFile(name string) ([]age.Identity, error) { return []age.Identity{&EncryptedIdentity{ Contents: contents, Passphrase: func() (string, error) { - pass, err := readSecret(fmt.Sprintf("Enter passphrase for identity file %q:", name)) + pass, err := tui.ReadSecret(fmt.Sprintf("Enter passphrase for identity file %q:", name)) if err != nil { return "", fmt.Errorf("could not read passphrase: %v", err) } return string(pass), nil }, NoMatchWarning: func() { - warningf("encrypted identity file %q didn't match file's recipients", name) + tui.Warningf("encrypted identity file %q didn't match file's recipients", name) }, }}, nil @@ -198,7 +199,7 @@ func parseIdentitiesFile(name string) ([]age.Identity, error) { func parseIdentity(s string) (age.Identity, error) { switch { case strings.HasPrefix(s, "AGE-PLUGIN-"): - return plugin.NewIdentity(s, pluginTerminalUI) + return plugin.NewIdentity(s, tui.PluginTerminalUI) case strings.HasPrefix(s, "AGE-SECRET-KEY-1"): return age.ParseX25519Identity(s) default: @@ -246,7 +247,7 @@ func parseSSHIdentity(name string, pemBytes []byte) ([]age.Identity, error) { } } passphrasePrompt := func() ([]byte, error) { - pass, err := readSecret(fmt.Sprintf("Enter passphrase for %q:", name)) + pass, err := tui.ReadSecret(fmt.Sprintf("Enter passphrase for %q:", name)) if err != nil { return nil, fmt.Errorf("could not read passphrase for %q: %v", name, err) } diff --git a/cmd/age/tui.go b/tui/tui.go similarity index 75% rename from cmd/age/tui.go rename to tui/tui.go index c0b1b13a..f0de9f53 100644 --- a/cmd/age/tui.go +++ b/tui/tui.go @@ -2,7 +2,7 @@ // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. -package main +package tui // This file implements the terminal UI of cmd/age. The rules are: // @@ -30,46 +30,47 @@ import ( // l is a logger with no prefixes. var l = log.New(os.Stderr, "", 0) -func printf(format string, v ...interface{}) { +func Printf(format string, v ...interface{}) { l.Printf("age: "+format, v...) } -func errorf(format string, v ...interface{}) { +func Errorf(format string, v ...interface{}) { l.Printf("age: error: "+format, v...) l.Printf("age: report unexpected or unhelpful errors at https://filippo.io/age/report") - exit(1) + Exit(1) } -func warningf(format string, v ...interface{}) { +func Warningf(format string, v ...interface{}) { l.Printf("age: warning: "+format, v...) } -func errorWithHint(error string, hints ...string) { +func ErrorWithHint(error string, hints ...string) { l.Printf("age: error: %s", error) for _, hint := range hints { l.Printf("age: hint: %s", hint) } l.Printf("age: report unexpected or unhelpful errors at https://filippo.io/age/report") - exit(1) + Exit(1) } -// If testOnlyPanicInsteadOfExit is true, exit will set testOnlyDidExit and +// If TestOnlyPanicInsteadOfExit is true, exit will set TestOnlyDidExit and // panic instead of calling os.Exit. This way, the wrapper in TestMain can // recover the panic and return the exit code only if it was originated in exit. -var testOnlyPanicInsteadOfExit bool -var testOnlyDidExit bool -func exit(code int) { - if testOnlyPanicInsteadOfExit { - testOnlyDidExit = true +var TestOnlyPanicInsteadOfExit bool +var TestOnlyDidExit bool + +func Exit(code int) { + if TestOnlyPanicInsteadOfExit { + TestOnlyDidExit = true panic(code) } os.Exit(code) } -// clearLine clears the current line on the terminal, or opens a new line if +// ClearLine clears the current line on the terminal, or opens a new line if // terminal escape codes don't work. -func clearLine(out io.Writer) { +func ClearLine(out io.Writer) { const ( CUI = "\033[" // Control Sequence Introducer CPL = CUI + "F" // Cursor Previous Line @@ -86,10 +87,10 @@ func clearLine(out io.Writer) { fmt.Fprintf(out, "\r\n"+CPL+EL) } -// withTerminal runs f with the terminal input and output files, if available. -// withTerminal does not open a non-terminal stdin, so the caller does not need +// WithTerminal runs f with the terminal input and output files, if available. +// WithTerminal does not open a non-terminal stdin, so the caller does not need // to check stdinInUse. -func withTerminal(f func(in, out *os.File) error) error { +func WithTerminal(f func(in, out *os.File) error) error { if runtime.GOOS == "windows" { in, err := os.OpenFile("CONIN$", os.O_RDWR, 0) if err != nil { @@ -112,18 +113,18 @@ func withTerminal(f func(in, out *os.File) error) error { } } -func printfToTerminal(format string, v ...interface{}) error { - return withTerminal(func(_, out *os.File) error { +func PrintfToTerminal(format string, v ...interface{}) error { + return WithTerminal(func(_, out *os.File) error { _, err := fmt.Fprintf(out, "age: "+format+"\n", v...) return err }) } -// readSecret reads a value from the terminal with no echo. The prompt is ephemeral. -func readSecret(prompt string) (s []byte, err error) { - err = withTerminal(func(in, out *os.File) error { +// ReadSecret reads a value from the terminal with no echo. The prompt is ephemeral. +func ReadSecret(prompt string) (s []byte, err error) { + err = WithTerminal(func(in, out *os.File) error { fmt.Fprintf(out, "%s ", prompt) - defer clearLine(out) + defer ClearLine(out) s, err = term.ReadPassword(int(in.Fd())) return err }) @@ -133,9 +134,9 @@ func readSecret(prompt string) (s []byte, err error) { // readCharacter reads a single character from the terminal with no echo. The // prompt is ephemeral. func readCharacter(prompt string) (c byte, err error) { - err = withTerminal(func(in, out *os.File) error { + err = WithTerminal(func(in, out *os.File) error { fmt.Fprintf(out, "%s ", prompt) - defer clearLine(out) + defer ClearLine(out) oldState, err := term.MakeRaw(int(in.Fd())) if err != nil { @@ -154,18 +155,18 @@ func readCharacter(prompt string) (c byte, err error) { return } -var pluginTerminalUI = &plugin.ClientUI{ +var PluginTerminalUI = &plugin.ClientUI{ DisplayMessage: func(name, message string) error { - printf("%s plugin: %s", name, message) + Printf("%s plugin: %s", name, message) return nil }, RequestValue: func(name, message string, _ bool) (s string, err error) { defer func() { if err != nil { - warningf("could not read value for age-plugin-%s: %v", name, err) + Warningf("could not read value for age-plugin-%s: %v", name, err) } }() - secret, err := readSecret(message) + secret, err := ReadSecret(message) if err != nil { return "", err } @@ -174,12 +175,12 @@ var pluginTerminalUI = &plugin.ClientUI{ Confirm: func(name, message, yes, no string) (choseYes bool, err error) { defer func() { if err != nil { - warningf("could not read value for age-plugin-%s: %v", name, err) + Warningf("could not read value for age-plugin-%s: %v", name, err) } }() if no == "" { message += fmt.Sprintf(" (press enter for %q)", yes) - _, err := readSecret(message) + _, err := ReadSecret(message) if err != nil { return false, err } @@ -199,16 +200,16 @@ var pluginTerminalUI = &plugin.ClientUI{ case '\x03': // CTRL-C return false, errors.New("user cancelled prompt") default: - warningf("reading value for age-plugin-%s: invalid selection %q", name, selection) + Warningf("reading value for age-plugin-%s: invalid selection %q", name, selection) } } }, WaitTimer: func(name string) { - printf("waiting on %s plugin...", name) + Printf("waiting on %s plugin...", name) }, } -func bufferTerminalInput(in io.Reader) (io.Reader, error) { +func BufferTerminalInput(in io.Reader) (io.Reader, error) { buf := &bytes.Buffer{} if _, err := buf.ReadFrom(ReaderFunc(func(p []byte) (n int, err error) { if bytes.Contains(buf.Bytes(), []byte(armor.Footer+"\n")) { From c264201d7f7a784a9db87875979ee8b76c0e8696 Mon Sep 17 00:00:00 2001 From: Alexander Yastrebov Date: Sun, 15 Sep 2024 12:07:03 +0200 Subject: [PATCH 2/2] Fix staticcheck warnings $ staticcheck ./... | fmt -w 80 -s internal/format/format.go:204:17: error strings should not end with punctuation or newlines (ST1005) internal/stream/stream.go:15:2: "golang.org/x/crypto/poly1305" is deprecated: Poly1305 as implemented by this package is a cryptographic building block that is not safe for general purpose use. For encryption, use the full ChaCha20-Poly1305 construction implemented by golang.org/x/crypto/chacha20poly1305. For authentication, use a general purpose MAC such as HMAC implemented by crypto/hmac. (SA1019) plugin/client.go:385:2: field stderr is unused (U1000) $ gotip vet ./cmd/... cmd/age-keygen/keygen.go:161:13: non-constant format string in call to log.Printf --- cmd/age-keygen/keygen.go | 2 +- internal/format/format.go | 2 +- internal/stream/stream.go | 3 +-- plugin/client.go | 2 -- 4 files changed, 3 insertions(+), 6 deletions(-) diff --git a/cmd/age-keygen/keygen.go b/cmd/age-keygen/keygen.go index 3971d371..549d3cf8 100644 --- a/cmd/age-keygen/keygen.go +++ b/cmd/age-keygen/keygen.go @@ -158,5 +158,5 @@ func errorf(format string, v ...interface{}) { } func warning(msg string) { - log.Printf("age-keygen: warning: " + msg) + log.Printf("age-keygen: warning: %s", msg) } diff --git a/internal/format/format.go b/internal/format/format.go index aa77b756..4382610e 100644 --- a/internal/format/format.go +++ b/internal/format/format.go @@ -201,7 +201,7 @@ func (r *StanzaReader) ReadStanza() (s *Stanza, err error) { b, err := DecodeString(strings.TrimSuffix(string(line), "\n")) if err != nil { if bytes.HasPrefix(line, footerPrefix) || bytes.HasPrefix(line, stanzaPrefix) { - return nil, fmt.Errorf("malformed body line %q: stanza ended without a short line\nNote: this might be a file encrypted with an old beta version of age or rage. Use age v1.0.0-beta6 or rage to decrypt it.", line) + return nil, fmt.Errorf("malformed body line %q: stanza ended without a short line\nNote: this might be a file encrypted with an old beta version of age or rage. Use age v1.0.0-beta6 or rage to decrypt it", line) } return nil, errorf("malformed body line %q: %v", line, err) } diff --git a/internal/stream/stream.go b/internal/stream/stream.go index 7cf02c47..7551274b 100644 --- a/internal/stream/stream.go +++ b/internal/stream/stream.go @@ -12,7 +12,6 @@ import ( "io" "golang.org/x/crypto/chacha20poly1305" - "golang.org/x/crypto/poly1305" ) const ChunkSize = 64 * 1024 @@ -29,7 +28,7 @@ type Reader struct { } const ( - encChunkSize = ChunkSize + poly1305.TagSize + encChunkSize = ChunkSize + chacha20poly1305.Overhead lastChunkFlag = 0x01 ) diff --git a/plugin/client.go b/plugin/client.go index dca1a521..2b50989a 100644 --- a/plugin/client.go +++ b/plugin/client.go @@ -9,7 +9,6 @@ package plugin import ( "bufio" - "bytes" "fmt" "io" "math/rand" @@ -382,7 +381,6 @@ type clientConnection struct { cmd *exec.Cmd io.Reader // stdout io.Writer // stdin - stderr bytes.Buffer close func() }