playground: support multiple input files in txtar format

Updates golang/go#32040
Updates golang/go#31944 (Notably, you can now include a go.mod file)

Change-Id: I56846e86d3d98fdf4cac388b5b284dbc187e3b36
Reviewed-on: https://go-review.googlesource.com/c/playground/+/177043
Reviewed-by: Dmitri Shuralyov <dmitshur@golang.org>
diff --git a/Dockerfile b/Dockerfile
index e6295da..27adf06 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -41,12 +41,17 @@
 ENV GOCACHE /gocache
 ENV GO111MODULE on
 
+COPY go.mod /go/src/playground/go.mod
+COPY go.sum /go/src/playground/go.sum
+WORKDIR /go/src/playground
+
 # Pre-build some packages to speed final install later.
 RUN go install cloud.google.com/go/compute/metadata
 RUN go install cloud.google.com/go/datastore
 RUN go install github.com/bradfitz/gomemcache/memcache
 RUN go install golang.org/x/tools/godoc/static
 RUN go install golang.org/x/tools/imports
+RUN go install github.com/rogpeppe/go-internal/txtar
 
 # Add and compile playground daemon
 COPY . /go/src/playground/
diff --git a/fmt.go b/fmt.go
index 09f50f2..c5aa943 100644
--- a/fmt.go
+++ b/fmt.go
@@ -20,25 +20,40 @@
 }
 
 func handleFmt(w http.ResponseWriter, r *http.Request) {
-	var (
-		in  = []byte(r.FormValue("body"))
-		out []byte
-		err error
-	)
-	if r.FormValue("imports") != "" {
-		out, err = imports.Process(progName, in, nil)
-	} else {
-		out, err = format.Source(in)
-	}
-	var resp fmtResponse
+	w.Header().Set("Content-Type", "application/json")
+
+	fs, err := splitFiles([]byte(r.FormValue("body")))
 	if err != nil {
-		resp.Error = err.Error()
-		// Prefix the error returned by format.Source.
-		if !strings.HasPrefix(resp.Error, progName) {
-			resp.Error = fmt.Sprintf("%v:%v", progName, resp.Error)
-		}
-	} else {
-		resp.Body = string(out)
+		json.NewEncoder(w).Encode(fmtResponse{Error: err.Error()})
+		return
 	}
-	json.NewEncoder(w).Encode(resp)
+
+	fixImports := r.FormValue("imports") != ""
+	for _, f := range fs.files {
+		if !strings.HasSuffix(f, ".go") {
+			continue
+		}
+		var out []byte
+		var err error
+		in := fs.m[f]
+		if fixImports {
+			// TODO: pass options to imports.Process so it
+			// can find symbols in sibling files.
+			out, err = imports.Process(progName, in, nil)
+		} else {
+			out, err = format.Source(in)
+		}
+		if err != nil {
+			errMsg := err.Error()
+			// Prefix the error returned by format.Source.
+			if !strings.HasPrefix(errMsg, f) {
+				errMsg = fmt.Sprintf("%v:%v", f, errMsg)
+			}
+			json.NewEncoder(w).Encode(fmtResponse{Error: errMsg})
+			return
+		}
+		fs.AddFile(f, out)
+	}
+
+	json.NewEncoder(w).Encode(fmtResponse{Body: string(fs.Format())})
 }
diff --git a/fmt_test.go b/fmt_test.go
new file mode 100644
index 0000000..b8a1b9a
--- /dev/null
+++ b/fmt_test.go
@@ -0,0 +1,84 @@
+// Copyright 2019 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package main
+
+import (
+	"encoding/json"
+	"net/http/httptest"
+	"net/url"
+	"strings"
+	"testing"
+)
+
+func TestHandleFmt(t *testing.T) {
+	for _, tt := range []struct {
+		name    string
+		body    string
+		imports bool
+		want    string
+		wantErr string
+	}{
+		{
+			name: "classic",
+			body: " package main\n    func main( ) {  }\n",
+			want: "package main\n\nfunc main() {}\n",
+		},
+		{
+			name:    "classic_goimports",
+			body:    " package main\nvar _ = fmt.Printf",
+			imports: true,
+			want:    "package main\n\nimport \"fmt\"\n\nvar _ = fmt.Printf\n",
+		},
+		{
+			name: "single_go_with_header",
+			body: "-- prog.go --\n  package main",
+			want: "-- prog.go --\npackage main\n",
+		},
+		{
+			name: "multi_go_with_header",
+			body: "-- prog.go --\n  package main\n\n\n-- two.go --\n   package main\n  var X = 5",
+			want: "-- prog.go --\npackage main\n-- two.go --\npackage main\n\nvar X = 5\n",
+		},
+		{
+			name: "multi_go_without_header",
+			body: "    package main\n\n\n-- two.go --\n   package main\n  var X = 5",
+			want: "package main\n-- two.go --\npackage main\n\nvar X = 5\n",
+		},
+		{
+			name: "only_format_go",
+			body: "    package main\n\n\n-- go.mod --\n   module foo\n",
+			want: "package main\n-- go.mod --\n   module foo\n",
+		},
+	} {
+		t.Run(tt.name, func(t *testing.T) {
+			rec := httptest.NewRecorder()
+			form := url.Values{}
+			form.Set("body", tt.body)
+			if tt.imports {
+				form.Set("imports", "true")
+			}
+			req := httptest.NewRequest("POST", "/fmt", strings.NewReader(form.Encode()))
+			req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+			handleFmt(rec, req)
+			resp := rec.Result()
+			if resp.StatusCode != 200 {
+				t.Fatalf("code = %v", resp.Status)
+			}
+			if ct := resp.Header.Get("Content-Type"); ct != "application/json" {
+				t.Fatalf("Content-Type = %q; want application/json", ct)
+			}
+			var got fmtResponse
+			if err := json.NewDecoder(resp.Body).Decode(&got); err != nil {
+				t.Fatal(err)
+			}
+			if got.Body != tt.want {
+				t.Errorf("wrong output\n got: %q\nwant: %q\n", got.Body, tt.want)
+			}
+			if got.Error != tt.wantErr {
+				t.Errorf("wrong error\n got err: %q\nwant err: %q\n", got.Error, tt.wantErr)
+			}
+		})
+	}
+}
diff --git a/go.mod b/go.mod
index f68be74..be07315 100644
--- a/go.mod
+++ b/go.mod
@@ -5,6 +5,7 @@
 require (
 	cloud.google.com/go v0.38.0
 	github.com/bradfitz/gomemcache v0.0.0-20190329173943-551aad21a668
+	github.com/rogpeppe/go-internal v1.3.0
 	golang.org/x/net v0.0.0-20190509222800-a4d6f7feada5 // indirect
 	golang.org/x/tools v0.0.0-20190513214131-2a413a02cc73
 )
diff --git a/go.sum b/go.sum
index 8e9d91d..8a315b3 100644
--- a/go.sum
+++ b/go.sum
@@ -22,6 +22,12 @@
 github.com/hashicorp/golang-lru v0.5.0 h1:CL2msUPvZTLb5O648aiLNJw3hnBxN2+1Jq8rCOH9wdo=
 github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
 github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
+github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
+github.com/kr/pty v1.1.1 h1:VkoXIwSboBpnk99O/KFauAEILuNHv5DVFKZMBN/gUgw=
+github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
+github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
+github.com/rogpeppe/go-internal v1.3.0 h1:RR9dF3JtopPvtkroDZuVD7qquD0bnHlKSqaQhgwt8yk=
+github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
 go.opencensus.io v0.21.0 h1:mU6zScU4U1YAFPHEHYk+3JC4SY7JxgkqS10ZOSyksNg=
 go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
 golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
@@ -69,5 +75,7 @@
 google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
 google.golang.org/grpc v1.19.0 h1:cfg4PD8YEdSFnm7qLV4++93WcmhH2nIUhMjhdCvl3j8=
 google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
+gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
 honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
 honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
diff --git a/sandbox.go b/sandbox.go
index 56e9613..c4a865d 100644
--- a/sandbox.go
+++ b/sandbox.go
@@ -311,33 +311,57 @@
 	}
 	defer os.RemoveAll(tmpDir)
 
-	src := []byte(req.Body)
-	in := filepath.Join(tmpDir, progName)
-	if err := ioutil.WriteFile(in, src, 0400); err != nil {
-		return nil, fmt.Errorf("error creating temp file %q: %v", in, err)
-	}
-
-	fset := token.NewFileSet()
-
-	f, err := parser.ParseFile(fset, in, nil, parser.PackageClauseOnly)
-	if err == nil && f.Name.Name != "main" {
-		return &response{Errors: "package name must be main"}, nil
+	files, err := splitFiles([]byte(req.Body))
+	if err != nil {
+		return &response{Errors: err.Error()}, nil
 	}
 
 	var testParam string
-	if code := getTestProg(src); code != nil {
-		testParam = "-test.v"
-		if err := ioutil.WriteFile(in, code, 0400); err != nil {
+	var buildPkgArg = "."
+	if files.Num() == 1 && len(files.Data(progName)) > 0 {
+		buildPkgArg = progName
+		src := files.Data(progName)
+		if code := getTestProg(src); code != nil {
+			testParam = "-test.v"
+			files.AddFile(progName, code)
+		}
+	}
+
+	useModules := allowModuleDownloads(files)
+	if !files.Contains("go.mod") && useModules {
+		files.AddFile("go.mod", []byte("module play\n"))
+	}
+
+	for f, src := range files.m {
+		// Before multi-file support we required that the
+		// program be in package main, so continue to do that
+		// for now. But permit anything in subdirectories to have other
+		// packages.
+		if !strings.Contains(f, "/") {
+			fset := token.NewFileSet()
+			f, err := parser.ParseFile(fset, f, src, parser.PackageClauseOnly)
+			if err == nil && f.Name.Name != "main" {
+				return &response{Errors: "package name must be main"}, nil
+			}
+		}
+
+		in := filepath.Join(tmpDir, f)
+		if strings.Contains(f, "/") {
+			if err := os.MkdirAll(filepath.Dir(in), 0755); err != nil {
+				return nil, err
+			}
+		}
+		if err := ioutil.WriteFile(in, src, 0644); err != nil {
 			return nil, fmt.Errorf("error creating temp file %q: %v", in, err)
 		}
 	}
 
 	exe := filepath.Join(tmpDir, "a.out")
 	goCache := filepath.Join(tmpDir, "gocache")
-	cmd := exec.Command("go", "build", "-o", exe, in)
+	cmd := exec.Command("go", "build", "-o", exe, buildPkgArg)
+	cmd.Dir = tmpDir
 	var goPath string
 	cmd.Env = []string{"GOOS=nacl", "GOARCH=amd64p32", "GOCACHE=" + goCache}
-	useModules := allowModuleDownloads(src)
 	if useModules {
 		// Create a GOPATH just for modules to be downloaded
 		// into GOPATH/pkg/mod.
@@ -356,9 +380,8 @@
 		if _, ok := err.(*exec.ExitError); ok {
 			// Return compile errors to the user.
 
-			// Rewrite compiler errors to refer to progName
-			// instead of '/tmp/sandbox1234/prog.go'.
-			errs := strings.Replace(string(out), in, progName, -1)
+			// Rewrite compiler errors to strip the tmpDir name.
+			errs := strings.Replace(string(out), tmpDir+"/", "", -1)
 
 			// "go build", invoked with a file name, puts this odd
 			// message before any compile errors; strip it.
@@ -422,8 +445,8 @@
 
 // allowModuleDownloads reports whether the code snippet in src should be allowed
 // to download modules.
-func allowModuleDownloads(src []byte) bool {
-	if bytes.Contains(src, []byte(`"code.google.com/p/go-tour/`)) {
+func allowModuleDownloads(files *fileSet) bool {
+	if files.Num() == 1 && bytes.Contains(files.Data(progName), []byte(`"code.google.com/p/go-tour/`)) {
 		// This domain doesn't exist anymore but we want old snippets using
 		// these packages to still run, so the Dockerfile adds these packages
 		// at this name in $GOPATH. Any snippets using this old name wouldn't
diff --git a/server_test.go b/server_test.go
index 4ff2f03..9cc19da 100644
--- a/server_test.go
+++ b/server_test.go
@@ -1,6 +1,7 @@
 // Copyright 2017 The Go Authors. All rights reserved.
 // Use of this source code is governed by a BSD-style
 // license that can be found in the LICENSE file.
+
 package main
 
 import (
@@ -10,6 +11,7 @@
 	"io/ioutil"
 	"net/http"
 	"net/http/httptest"
+	"os"
 	"testing"
 )
 
@@ -240,3 +242,35 @@
 		}
 	}
 }
+
+func TestAllowModuleDownloads(t *testing.T) {
+	const envKey = "ALLOW_PLAY_MODULE_DOWNLOADS"
+	defer func(old string) { os.Setenv(envKey, old) }(os.Getenv(envKey))
+
+	tests := []struct {
+		src  string
+		env  string
+		want bool
+	}{
+		{src: "package main", want: true},
+		{src: "package main", env: "false", want: false},
+		{src: `import "code.google.com/p/go-tour/"`, want: false},
+	}
+	for i, tt := range tests {
+		if tt.env != "" {
+			os.Setenv(envKey, tt.env)
+		} else {
+			os.Setenv(envKey, "true")
+		}
+		files, err := splitFiles([]byte(tt.src))
+		if err != nil {
+			t.Errorf("%d. splitFiles = %v", i, err)
+			continue
+		}
+		got := allowModuleDownloads(files)
+		if got != tt.want {
+			t.Errorf("%d. allow = %v; want %v; files:\n%s", i, got, tt.want, filesAsString(files))
+		}
+	}
+
+}
diff --git a/tests.go b/tests.go
index 437da2b..18f6ea0 100644
--- a/tests.go
+++ b/tests.go
@@ -57,6 +57,9 @@
 		if resp.VetErrors != t.wantVetErrors {
 			stdlog.Fatalf("resp.VetErrs = %q, want %q", resp.VetErrors, t.wantVetErrors)
 		}
+		if t.withVet && (resp.VetErrors != "") == resp.VetOK {
+			stdlog.Fatalf("resp.VetErrs & VetOK inconsistent; VetErrs = %q; VetOK = %v", resp.VetErrors, resp.VetOK)
+		}
 		if len(resp.Events) == 0 {
 			stdlog.Fatalf("unexpected output: %q, want %q", "", t.want)
 		}
@@ -238,7 +241,7 @@
 func ExampleNotExecuted() {
 	// Output: it should not run
 }
-`, want: "", errors: "prog.go:4:20: undefined: testing\n"},
+`, want: "", errors: "./prog.go:4:20: undefined: testing\n"},
 
 	{
 		name: "test_with_import_ignored",
@@ -406,7 +409,7 @@
 	{
 		name:          "compile_with_vet",
 		withVet:       true,
-		wantVetErrors: "prog.go:5:2: Printf format %v reads arg #1, but call has 0 args\n",
+		wantVetErrors: "./prog.go:5:2: Printf format %v reads arg #1, but call has 0 args\n",
 		prog: `
 package main
 import "fmt"
@@ -431,7 +434,7 @@
 	{
 		name:          "compile_modules_with_vet",
 		withVet:       true,
-		wantVetErrors: "prog.go:6:2: Printf format %v reads arg #1, but call has 0 args\n",
+		wantVetErrors: "./prog.go:6:2: Printf format %v reads arg #1, but call has 0 args\n",
 		prog: `
 package main
 import ("fmt"; "github.com/bradfitz/iter")
@@ -441,4 +444,45 @@
 }
 `,
 	},
+
+	{
+		name: "multi_file_basic",
+		prog: `
+package main
+const foo = "bar"
+
+-- two.go --
+package main
+func main() {
+  println(foo)
+}
+`,
+		wantEvents: []Event{
+			{"bar\n", "stderr", 0},
+		},
+	},
+
+	{
+		name:    "multi_file_use_package",
+		withVet: true,
+		prog: `
+package main
+
+import "play.test/foo"
+
+func main() {
+    foo.Hello()
+}
+
+-- go.mod --
+module play.test
+
+-- foo/foo.go --
+package foo
+
+import "fmt"
+
+func Hello() { fmt.Println("hello world") }
+`,
+	},
 }
diff --git a/txtar.go b/txtar.go
new file mode 100644
index 0000000..9b699bf
--- /dev/null
+++ b/txtar.go
@@ -0,0 +1,121 @@
+// Copyright 2019 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package main
+
+import (
+	"bytes"
+	"errors"
+	"fmt"
+	"path"
+	"strings"
+
+	"github.com/rogpeppe/go-internal/txtar"
+)
+
+// fileSet is a set of files.
+// The zero value for fileSet is an empty set ready to use.
+type fileSet struct {
+	files    []string          // filenames in user-provided order
+	m        map[string][]byte // filename -> source
+	noHeader bool              // whether the prog.go entry was implicit
+}
+
+// Data returns the content of the named file.
+// The fileSet retains ownership of the returned slice.
+func (fs *fileSet) Data(filename string) []byte { return fs.m[filename] }
+
+// Num returns the number of files in the set.
+func (fs *fileSet) Num() int { return len(fs.m) }
+
+// Contains reports whether fs contains the given filename.
+func (fs *fileSet) Contains(filename string) bool {
+	_, ok := fs.m[filename]
+	return ok
+}
+
+// AddFile adds a file to fs. If fs already contains filename, its
+// contents are replaced.
+func (fs *fileSet) AddFile(filename string, src []byte) {
+	had := fs.Contains(filename)
+	if fs.m == nil {
+		fs.m = make(map[string][]byte)
+	}
+	fs.m[filename] = src
+	if !had {
+		fs.files = append(fs.files, filename)
+	}
+}
+
+// Format returns fs formatted as a txtar archive.
+func (fs *fileSet) Format() []byte {
+	a := new(txtar.Archive)
+	if fs.noHeader {
+		a.Comment = fs.m[progName]
+	}
+	for i, f := range fs.files {
+		if i == 0 && f == progName && fs.noHeader {
+			continue
+		}
+		a.Files = append(a.Files, txtar.File{Name: f, Data: fs.m[f]})
+	}
+	return txtar.Format(a)
+}
+
+// splitFiles splits the user's input program src into 1 or more
+// files, splitting it based on boundaries as specified by the "txtar"
+// format. It returns an error if any filenames are bogus or
+// duplicates. The implicit filename for the txtar comment (the lines
+// before any txtar separator line) are named "prog.go". It is an
+// error to have an explicit file named "prog.go" in addition to
+// having the implicit "prog.go" file (non-empty comment section).
+//
+// The filenames are validated to only be relative paths, not too
+// long, not too deep, not have ".." elements, not have backslashes or
+// low ASCII binary characters, and to be in path.Clean canonical
+// form.
+//
+// splitFiles takes ownership of src.
+func splitFiles(src []byte) (*fileSet, error) {
+	fs := new(fileSet)
+	a := txtar.Parse(src)
+	if v := bytes.TrimSpace(a.Comment); len(v) > 0 {
+		fs.noHeader = true
+		fs.AddFile(progName, a.Comment)
+	}
+	const limitNumFiles = 20 // arbitrary
+	numFiles := len(a.Files) + fs.Num()
+	if numFiles > limitNumFiles {
+		return nil, fmt.Errorf("too many files in txtar archive (%v exceeds limit of %v)", numFiles, limitNumFiles)
+	}
+	for _, f := range a.Files {
+		if len(f.Name) > 200 { // arbitrary limit
+			return nil, errors.New("file name too long")
+		}
+		if strings.IndexFunc(f.Name, isBogusFilenameRune) != -1 {
+			return nil, fmt.Errorf("invalid file name %q", f.Name)
+		}
+		if f.Name != path.Clean(f.Name) || path.IsAbs(f.Name) {
+			return nil, fmt.Errorf("invalid file name %q", f.Name)
+		}
+		parts := strings.Split(f.Name, "/")
+		if len(parts) > 10 { // arbitrary limit
+			return nil, fmt.Errorf("file name %q too deep", f.Name)
+		}
+		for _, part := range parts {
+			if part == "." || part == ".." {
+				return nil, fmt.Errorf("invalid file name %q", f.Name)
+			}
+		}
+		if fs.Contains(f.Name) {
+			return nil, fmt.Errorf("duplicate file name %q", f.Name)
+		}
+		fs.AddFile(f.Name, f.Data)
+	}
+	return fs, nil
+}
+
+// isBogusFilenameRune reports whether r should be rejected if it
+// appears in a txtar section's filename.
+func isBogusFilenameRune(r rune) bool { return r == '\\' || r < ' ' }
diff --git a/txtar_test.go b/txtar_test.go
new file mode 100644
index 0000000..ae1ef96
--- /dev/null
+++ b/txtar_test.go
@@ -0,0 +1,153 @@
+// Copyright 2019 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package main
+
+import (
+	"fmt"
+	"reflect"
+	"strings"
+	"testing"
+)
+
+func newFileSet(kv ...string) *fileSet {
+	fs := new(fileSet)
+	if kv[0] == "prog.go!implicit" {
+		fs.noHeader = true
+		kv[0] = "prog.go"
+	}
+	for len(kv) > 0 {
+		fs.AddFile(kv[0], []byte(kv[1]))
+		kv = kv[2:]
+	}
+	return fs
+}
+
+func TestSplitFiles(t *testing.T) {
+	for _, tt := range []struct {
+		name    string
+		in      string
+		want    *fileSet
+		wantErr string
+	}{
+		{
+			name: "classic",
+			in:   "package main",
+			want: newFileSet("prog.go!implicit", "package main\n"),
+		},
+		{
+			name: "implicit prog.go",
+			in:   "package main\n-- two.go --\nsecond",
+			want: newFileSet(
+				"prog.go!implicit", "package main\n",
+				"two.go", "second\n",
+			),
+		},
+		{
+			name: "basic txtar",
+			in:   "-- main.go --\npackage main\n-- foo.go --\npackage main\n",
+			want: newFileSet(
+				"main.go", "package main\n",
+				"foo.go", "package main\n",
+			),
+		},
+		{
+			name:    "reject dotdot 1",
+			in:      "-- ../foo --\n",
+			wantErr: `invalid file name "../foo"`,
+		},
+		{
+			name:    "reject dotdot 2",
+			in:      "-- .. --\n",
+			wantErr: `invalid file name ".."`,
+		},
+		{
+			name:    "reject dotdot 3",
+			in:      "-- bar/../foo --\n",
+			wantErr: `invalid file name "bar/../foo"`,
+		},
+		{
+			name:    "reject long",
+			in:      "-- xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx --\n",
+			wantErr: `file name too long`,
+		},
+		{
+			name:    "reject deep",
+			in:      "-- x/x/x/x/x/x/x/x/x/x/x/x/x/x/x/x/x/x/x/x/x --\n",
+			wantErr: `file name "x/x/x/x/x/x/x/x/x/x/x/x/x/x/x/x/x/x/x/x/x" too deep`,
+		},
+		{
+			name:    "reject abs",
+			in:      "-- /etc/passwd --\n",
+			wantErr: `invalid file name "/etc/passwd"`,
+		},
+		{
+			name:    "reject backslash",
+			in:      "-- foo\\bar --\n",
+			wantErr: `invalid file name "foo\\bar"`,
+		},
+		{
+			name:    "reject binary null",
+			in:      "-- foo\x00bar --\n",
+			wantErr: `invalid file name "foo\x00bar"`,
+		},
+		{
+			name:    "reject binary low",
+			in:      "-- foo\x1fbar --\n",
+			wantErr: `invalid file name "foo\x1fbar"`,
+		},
+		{
+			name:    "reject dup",
+			in:      "-- foo.go --\n-- foo.go --\n",
+			wantErr: `duplicate file name "foo.go"`,
+		},
+		{
+			name:    "reject implicit dup",
+			in:      "package main\n-- prog.go --\n",
+			wantErr: `duplicate file name "prog.go"`,
+		},
+		{
+			name: "skip leading whitespace comment",
+			in:   "\n    \n\n   \n\n-- f.go --\ncontents",
+			want: newFileSet("f.go", "contents\n"),
+		},
+		{
+			name:    "reject many files",
+			in:      strings.Repeat("-- x.go --\n", 50),
+			wantErr: `too many files in txtar archive (50 exceeds limit of 20)`,
+		},
+	} {
+		got, err := splitFiles([]byte(tt.in))
+		var gotErr string
+		if err != nil {
+			gotErr = err.Error()
+		}
+		if gotErr != tt.wantErr {
+			if tt.wantErr == "" {
+				t.Errorf("%s: unexpected error: %v", tt.name, err)
+				continue
+			}
+			t.Errorf("%s: error = %#q; want error %#q", tt.name, err, tt.wantErr)
+			continue
+		}
+		if err != nil {
+			continue
+		}
+		if !reflect.DeepEqual(got, tt.want) {
+			t.Errorf("%s: wrong files\n got:\n%s\nwant:\n%s", tt.name, filesAsString(got), filesAsString(tt.want))
+		}
+	}
+}
+
+func filesAsString(fs *fileSet) string {
+	var sb strings.Builder
+	for i, f := range fs.files {
+		var implicit string
+		if i == 0 && f == progName && fs.noHeader {
+			implicit = " (implicit)"
+		}
+		fmt.Fprintf(&sb, "[file %q%s]: %q\n", f, implicit, fs.m[f])
+	}
+	return sb.String()
+}
diff --git a/vet.go b/vet.go
index 78df95c..32edeca 100644
--- a/vet.go
+++ b/vet.go
@@ -48,8 +48,11 @@
 // vet successfully found nothing, and (non-empty, nil) if vet ran and
 // found issues.
 func vetCheckInDir(dir, goPath string, modules bool) (output string, execErr error) {
-	in := filepath.Join(dir, progName)
-	cmd := exec.Command("go", "vet", in)
+	cmd := exec.Command("go", "vet")
+	if !modules {
+		cmd.Args = append(cmd.Args, progName)
+	}
+	cmd.Dir = dir
 	// Linux go binary is not built with CGO_ENABLED=0.
 	// Prevent vet to compile packages in cgo mode.
 	// See #26307.
@@ -70,11 +73,13 @@
 
 	// Rewrite compiler errors to refer to progName
 	// instead of '/tmp/sandbox1234/main.go'.
-	errs := strings.Replace(string(out), in, progName, -1)
+	errs := strings.Replace(string(out), dir, "", -1)
 
-	// "go vet", invoked with a file name, puts this odd
-	// message before any compile errors; strip it.
-	errs = strings.Replace(errs, "# command-line-arguments\n", "", 1)
-
+	// Remove vet's package name banner.
+	if strings.HasPrefix(errs, "#") {
+		if nl := strings.Index(errs, "\n"); nl != -1 {
+			errs = errs[nl+1:]
+		}
+	}
 	return errs, nil
 }