internal/sandbox: generalize the API

Change the sandbox API so it is more like os/exec, where
one constructs a Cmd and then calls Output to get the output.

This not only makes the API more familiar, it also generalizes it:
now it is possible to set the working directory and environment
for binaries run in the sandbox.

Change-Id: I12972b5a553d98c002c61ef98b0eb690efeb9257
Reviewed-on: https://go-review.googlesource.com/c/pkgsite-metrics/+/471416
TryBot-Result: Gopher Robot <gobot@golang.org>
Reviewed-by: Maceo Thompson <maceothompson@google.com>
Run-TryBot: Jonathan Amsterdam <jba@google.com>
diff --git a/internal/sandbox/runner.go b/internal/sandbox/runner.go
index d76b88f..77fa922 100644
--- a/internal/sandbox/runner.go
+++ b/internal/sandbox/runner.go
@@ -14,12 +14,12 @@
 
 import (
 	"bytes"
+	"encoding/json"
 	"errors"
 	"io"
 	"log"
 	"os"
 	"os/exec"
-	"strings"
 )
 
 func main() {
@@ -31,11 +31,11 @@
 		log.Fatal(err)
 	}
 	log.Printf("read %q", in)
-	args := strings.Fields(string(in))
-	if len(args) == 0 {
-		log.Fatal("no args")
+	var cmd exec.Cmd
+	if err := json.Unmarshal(in, &cmd); err != nil {
+		log.Fatal(err)
 	}
-	cmd := exec.Command(args[0], args[1:]...)
+	log.Printf("cmd: %+v", cmd)
 	out, err := cmd.Output()
 	if err != nil {
 		s := err.Error()
@@ -43,7 +43,7 @@
 		if errors.As(err, &eerr) {
 			s += ": " + string(bytes.TrimSpace(eerr.Stderr))
 		}
-		log.Fatalf("%s failed with %s", args[0], s)
+		log.Fatalf("%v failed with %s", cmd.Args, s)
 	}
 	if _, err := os.Stdout.Write(out); err != nil {
 		log.Fatal(err)
diff --git a/internal/sandbox/sandbox.go b/internal/sandbox/sandbox.go
index 0625d1d..c2b0855 100644
--- a/internal/sandbox/sandbox.go
+++ b/internal/sandbox/sandbox.go
@@ -7,12 +7,9 @@
 
 import (
 	"bytes"
-	"context"
+	"encoding/json"
 	"fmt"
-	"io"
 	"os/exec"
-	"strings"
-	"unicode"
 
 	"golang.org/x/pkgsite-metrics/internal/derrors"
 )
@@ -37,44 +34,74 @@
 	}
 }
 
-// Run runs program with args in a sandbox.
-// The program argument is the absolute path to the program from
-// within the sandbox.
-// It is invoked directly, as with [exec.Command]; no shell
-// interpretation is performed.
-// Its working directory is the bundle filesystem root.
-// The program is passed the given arguments, which must not contain whitespace.
-//
-// If the program succeeds (exits with code 0), its standard output is returned.
-// If it fails, the first return value is empty and the error comes from [exec.Command.Output].
-func (s *Sandbox) Run(ctx context.Context, program string, args ...string) (stdout []byte, err error) {
-	defer derrors.Wrap(&err, "Run(%s, %q)", program, args)
-	for _, a := range args {
-		if strings.IndexFunc(a, unicode.IsSpace) >= 0 {
-			return nil, fmt.Errorf("arg %q contains whitespace", a)
-		}
-	}
+// Cmd's exported fields must be a subset of the exported fields of exec.Cmd.
+// runner.go must be able to unmarshal a sandbox.Cmd into an exec.Cmd.
 
+// Cmd describes how to run a binary in a sandbox.
+type Cmd struct {
+	sb *Sandbox
+
+	// 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 whatever environment
+	// runsc provides by default.
+	Env []string
+
+	// Dir specifies the working directory of the command.
+	// If Dir is the empty string, Run runs the command in the
+	// root of the sandbox filesystem.
+	Dir string
+}
+
+// Command creates a *Cmd to run path in the sandbox.
+// It behaves like [os/exec.Command].
+func (s *Sandbox) Command(path string, arg ...string) *Cmd {
+	return &Cmd{
+		sb:   s,
+		Path: path,
+		Args: append([]string{path}, arg...),
+	}
+}
+
+// Output runs Cmd in the sandbox used to create it, and returns its standard output.
+func (c *Cmd) Output() (_ []byte, err error) {
+	defer derrors.Wrap(&err, "Cmd.Output %q", c.Args)
 	// -ignore-cgroups is needed to avoid this error from runsc:
 	// cannot set up cgroup for root: configuring cgroup: write /sys/fs/cgroup/cgroup.subtree_control: device or resource busy
-	cmd := exec.CommandContext(ctx, s.Runsc, "-ignore-cgroups", "-network=none", "run", "sandbox")
-	cmd.Dir = s.bundleDir
+	cmd := exec.Command(c.sb.Runsc, "-ignore-cgroups", "-network=none", "run", "sandbox")
+	cmd.Dir = c.sb.bundleDir
 	stdinPipe, err := cmd.StdinPipe()
 	if err != nil {
 		return nil, err
 	}
-	stdin := program + " " + strings.Join(args, " ")
-	c := make(chan error, 1)
+	stdin, err := json.Marshal(c)
+	if err != nil {
+		return nil, err
+	}
+	ch := make(chan error, 1)
 	go func() {
-		_, err := io.WriteString(stdinPipe, stdin)
+		_, err := stdinPipe.Write(stdin)
 		stdinPipe.Close()
-		c <- err
+		ch <- err
 	}()
 	out, err := cmd.Output()
 	if err != nil {
 		return nil, err
 	}
-	if err := <-c; err != nil {
+	if err := <-ch; err != nil {
 		return nil, fmt.Errorf("writing stdin: %w", err)
 	}
 	return bytes.TrimSpace(out), nil
diff --git a/internal/sandbox/sandbox_test.go b/internal/sandbox/sandbox_test.go
index 536ca9f..60fa51f 100644
--- a/internal/sandbox/sandbox_test.go
+++ b/internal/sandbox/sandbox_test.go
@@ -5,7 +5,6 @@
 package sandbox
 
 import (
-	"context"
 	"errors"
 	"os"
 	"os/exec"
@@ -23,12 +22,11 @@
 	if os.Getenv("RUN_FROM_MAKE") != "1" {
 		t.Skip("skipping; must run with 'make'.")
 	}
-	ctx := context.Background()
 	sb := New("testdata/bundle")
 	sb.Runsc = "/usr/local/bin/runsc" // must match path in Makefile
 
 	t.Run("printargs", func(t *testing.T) {
-		out, err := sb.Run(ctx, "/printargs", "a", "b")
+		out, err := sb.Command("printargs", "a", "b").Output()
 		if err != nil {
 			t.Fatal(derrors.IncludeStderr(err))
 		}
@@ -43,17 +41,20 @@
 	})
 
 	t.Run("space in arg", func(t *testing.T) {
-		_, err := sb.Run(ctx, "foo", "a b c")
-		if err == nil {
-			t.Fatal("got nil, want error")
+		out, err := sb.Command("printargs", "a", "b c\td").Output()
+		if err != nil {
+			t.Fatal(derrors.IncludeStderr(err))
 		}
-		if g, w := err.Error(), "contains whitespace"; !strings.Contains(g, w) {
-			t.Fatalf("got\n%q\nwhich does not contain %q", g, w)
+		want := `args:
+0: "a"
+1: "b c\td"`
+		got := string(out)
+		if got != want {
+			t.Fatalf("got\n%q\nwant\n%q", got, want)
 		}
 	})
-
 	t.Run("no program", func(t *testing.T) {
-		_, err := sb.Run(ctx, "foo")
+		_, err := sb.Command("foo").Output()
 		var eerr *exec.ExitError
 		if !errors.As(err, &eerr) {
 			t.Fatalf("got %T, wanted *exec.ExitError", err)
@@ -61,7 +62,7 @@
 		if g, w := eerr.ExitCode(), 1; g != w {
 			t.Fatalf("got exit code %d, wanted %d", g, w)
 		}
-		if g, w := string(eerr.Stderr), "executable file not found"; !strings.Contains(g, w) {
+		if g, w := string(eerr.Stderr), "no such file"; !strings.Contains(g, w) {
 			t.Fatalf("got\n%q\nwhich does not contain %q", g, w)
 		}
 	})
diff --git a/internal/worker/vulncheck_scan.go b/internal/worker/vulncheck_scan.go
index c509b04..066446b 100644
--- a/internal/worker/vulncheck_scan.go
+++ b/internal/worker/vulncheck_scan.go
@@ -345,7 +345,7 @@
 	}
 	log.Infof(ctx, "go mod download succeeded")
 	log.Infof(ctx, "%s@%s: running vulncheck in sandbox", modulePath, version)
-	stdout, err := sbox.Run(ctx, "/binaries/vulncheck_sandbox", mode, sandboxDir)
+	stdout, err := sbox.Command("/binaries/vulncheck_sandbox", mode, sandboxDir).Output()
 	if err != nil {
 		return nil, errors.New(derrors.IncludeStderr(err))
 	}
@@ -374,7 +374,7 @@
 		return nil, err
 	}
 	log.Infof(ctx, "%s@%s/%s: running vulncheck in sandbox on %s", modulePath, version, binDir, destf.Name())
-	stdout, err := s.sbox.Run(ctx, "/binaries/vulncheck_sandbox", ModeBinary, destf.Name())
+	stdout, err := s.sbox.Command("/binaries/vulncheck_sandbox", ModeBinary, destf.Name()).Output()
 	if err != nil {
 		return nil, errors.New(derrors.IncludeStderr(err))
 	}
@@ -762,7 +762,7 @@
 		out, err = exec.Command("go", "clean", "-cache", "-modcache").CombinedOutput()
 	} else {
 		logDiskUsage("before")
-		out, err = s.sbox.Run(ctx, "go", "clean", "-cache", "-modcache")
+		out, err = s.sbox.Command("go", "clean", "-cache", "-modcache").Output()
 		if err == nil {
 			logDiskUsage("after")
 		}