Capturing input with $EDITOR in a Golang CLI program

When it comes to developer tooling and orchestration, the command line is still the name of the game. Whether you're developing front end web applications or building highly distributed systems, it is unlikely you don't interact with the command line on a daily basis. Often times it's a program built by other companies and organizations like Git or NPM. Sometimes, however, you find yourself writing your own programs that run on the good ole fashioned command line, like those who came before us. Today I found myself in such a scenario and had to solve an interesting problem, or, at least one I'd never had to solve before: capturing sensitive data in a user-friendly way within an existing Golang CLI application.

There were two initial approaches that came to mind. Probably the most typical, implementing a sort of getpasswd approach is the first solution I thought of. In fact, it is quite easy to do with the terminal package:

package main

import (
    "fmt"
    "syscall"

    "golang.org/x/crypto/ssh/terminal"
)

func main() {
    fmt.Print("Secret value: ")
    bytes, err := terminal.ReadPassword(int(syscall.Stdin))

    // error handling

    // do some stuff with bytes
}

This would be sufficient for capturing simple secrets like passwords or API keys. However, for my purposes I needed to provide the ability to paste and/or type complex values without exposing them on the command line. I immediately thought of the way Git opens up an interactive editor when you commit or perform an interactive rebase. Implementing something similar seemed like a pretty reasonable solution. When the user runs some command, the program opens up a text editor, captures the input, and continues on without leaving the sensitive data on screen. For my purposes, this was sufficient security. It also seemed easy enough, so I decided to go with that approach.

Implementation

As it turns out, opening an editor in a Go program is pretty damn easy:

package main

import (
	"os"
	"os/exec"	
)

func main() {
	cmd := exec.Command("vim", "/tmp/foo.txt")
	cmd.Stdin = os.Stdin
	cmd.Stdout = os.Stdout
	cmd.Stderr = os.Stderr
    
	err := cmd.Run()
}

Easy enough, but there's a lot more that needs to happen here. Let's list out the steps that need to happen when a user runs our command, c:

  1. Create a file that will serve to temporarily capture the user's input while they leave our program
  2. Open the file in a text editor. We want to be good UX engineers, so bonus points if it opens up in the user's preferred editor (`$EDITOR`)
  3. When the user exits the editor, read the temporary file into our program
  4. Delete the temporary file

From here on it's a pretty straightforward implementation. We have some basic file I/O, some environment variable lookups, and some other low-level OS stuff. A barebones implementation might look something like the following, keeping in mind I like to write anything that could be reused as a library:

package cli

import (
	"io/ioutil"
	"os"
	"os/exec"
)

// DefaultEditor is vim because we're adults ;)
const DefaultEditor = "vim"

// OpenFileInEditor opens filename in a text editor.
func OpenFileInEditor(filename string) error {
	editor := os.Getenv("EDITOR")
	if editor == "" {
		editor = DefaultEditor
	}

	// Get the full executable path for the editor.
	executable, err := exec.LookPath(editor)
	if err != nil {
		return err
	}

	cmd := exec.Command(executable, filename)
	cmd.Stdin = os.Stdin
	cmd.Stdout = os.Stdout
	cmd.Stderr = os.Stderr

	return cmd.Run()
}

// CaptureInputFromEditor opens a temporary file in a text editor and returns
// the written bytes on success or an error on failure. It handles deletion
// of the temporary file behind the scenes.
func CaptureInputFromEditor() ([]byte, error) {
	file, err := ioutil.TempFile(os.TempDir(), "*")
	if err != nil {
		return []byte{}, err
	}

	filename := file.Name()

	// Defer removal of the temporary file in case any of the next steps fail.
	defer os.Remove(filename)

	if err = file.Close(); err != nil {
		return []byte{}, err
	}

	if err = OpenFileInEditor(filename); err != nil {
		return []byte{}, err
	}

	bytes, err := ioutil.ReadFile(filename)
	if err != nil {
		return []byte{}, err
	}

	return bytes, nil
}

Now that we have our little API, nicely packaged up inside cli, we can use it like so:

package main

import (
	"/path/to/pkg/cli"
)

func main() {
	sensitiveBytes, err := cli.CaptureInputFromEditor()
}

Not too shabby. Unfortunately there is not too much useful unit testing that can be done here without mocking out the filesystem and even then we wouldn't be able to test out the meat of it. Generally I like to adhere to the principal that any program which interacts with an environment outside of its own should be resilient to changes in that environment. However, the Go standard library for file I/O is relatively stable and I don't anticipate ever having to swap out implementations here so I think it's fine.

That said, there are still some issues that this basic program does not currently address. One is that it's vulnerable to remote code execution via the $EDITOR environment variable. The second is that we can't provide other ways of getting the user's preferred editor. And the third is that some common editors require additional flags to block execution until they're closed. For example, you have to pass the --wait flag to VS Code when launching it from another process or it will simply launch in a different routine. We can solve the first two issues with a PreferredEditorResolver interface.

package cli

import (
	"os"
)

// DefaultEditor is vim because we're adults ;)
const DefaultEditor = "vim"

// PreferredEditorResolver is a function that returns an editor that the user
// prefers to use, such as the configured `$EDITOR` environment variable.
type PreferredEditorResolver func() string

// GetPreferredEditorFromEnvironment returns the user's editor as defined by the
// `$EDITOR` environment variable, or the `DefaultEditor` if it is not set.
func GetPreferredEditorFromEnvironment() string {
	editor := os.Getenv("EDITOR")

	if editor == "" {
		return DefaultEditor
	}

	return editor
}

One thing I really love about Golang and its type system is the ability to define super basic interfaces as typed functions. I'm going to skip validation here because there's a ton of ways it can be implemented and frankly it feels like overkill for a homegrown tool used internally by a small team.

We also need a function to resolve editor arguments based on the executable we're going to run. Something simple but flexible should do the trick:

func resolveEditorArguments(executable string, filename string) []string {
    args := []string{filename}

    if strings.Contains(executable, "Visual Studio Code.app") {
    	args = append([]string{"--wait"}, args...)
    }
    
    // Other common editors

    return args
}

Tying it all together, we'll add a PreferredEditorResolver argument to OpenFileInEditor() and unpack the results of resolveEditorArguments() in our command constructor:

package cli

import (
	"io/ioutil"
	"os"
	"os/exec"
)

// DefaultEditor is vim because we're adults ;)
const DefaultEditor = "vim"

// PreferredEditorResolver is a function that returns an editor that the user
// prefers to use, such as the configured `$EDITOR` environment variable.
type PreferredEditorResolver func() string

// GetPreferredEditorFromEnvironment returns the user's editor as defined by the
// `$EDITOR` environment variable, or the `DefaultEditor` if it is not set.
func GetPreferredEditorFromEnvironment() string {
	editor := os.Getenv("EDITOR")

	if editor == "" {
		return DefaultEditor
	}

	return editor
}

func resolveEditorArguments(executable string, filename string) []string {
    args := []string{filename}

    if strings.Contains(executable, "Visual Studio Code.app") {
    	args = append([]string{"--wait"}, args...)
    }
    
    // Other common editors

    return args
}

// OpenFileInEditor opens filename in a text editor.
func OpenFileInEditor(filename string, resolveEditor PreferredEditorResolver) error {
	// Get the full executable path for the editor.
	executable, err := exec.LookPath(resolveEditor())
	if err != nil {
		return err
	}

	cmd := exec.Command(executable, resolveEditorArguments(executable, filename)...)
	cmd.Stdin = os.Stdin
	cmd.Stdout = os.Stdout
	cmd.Stderr = os.Stderr

	return cmd.Run()
}

// CaptureInputFromEditor opens a temporary file in a text editor and returns
// the written bytes on success or an error on failure. It handles deletion
// of the temporary file behind the scenes.
func CaptureInputFromEditor(resolveEditor PreferredEditorResolver) ([]byte, error) {
	file, err := ioutil.TempFile(os.TempDir(), "*")
	if err != nil {
		return []byte{}, err
	}

	filename := file.Name()

	// Defer removal of the temporary file in case any of the next steps fail.
	defer os.Remove(filename)

	if err = file.Close(); err != nil {
		return []byte{}, err
	}

	if err = OpenFileInEditor(filename, resolveEditor); err != nil {
		return []byte{}, err
	}

	bytes, err := ioutil.ReadFile(filename)
	if err != nil {
		return []byte{}, err
	}

	return bytes, nil
}
package main

import (
	"/path/to/pkg/cli"
)

func main() {
    sensitiveBytes, err := cli.CaptureInputFromEditor(
    	cli.GetPreferredEditorFromEnvironment,
    )
}

And there you have it. A super lightweight, fairly well-abstracted way to capture input in an external editor from within your Golang CLI program.

Show Comments