summaryrefslogtreecommitdiffstats
path: root/src/cmd/go/internal/vcweb
diff options
context:
space:
mode:
Diffstat (limited to 'src/cmd/go/internal/vcweb')
-rw-r--r--src/cmd/go/internal/vcweb/auth.go108
-rw-r--r--src/cmd/go/internal/vcweb/bzr.go18
-rw-r--r--src/cmd/go/internal/vcweb/dir.go19
-rw-r--r--src/cmd/go/internal/vcweb/fossil.go61
-rw-r--r--src/cmd/go/internal/vcweb/git.go71
-rw-r--r--src/cmd/go/internal/vcweb/hg.go123
-rw-r--r--src/cmd/go/internal/vcweb/insecure.go42
-rw-r--r--src/cmd/go/internal/vcweb/script.go345
-rw-r--r--src/cmd/go/internal/vcweb/svn.go199
-rw-r--r--src/cmd/go/internal/vcweb/vcstest/vcstest.go169
-rw-r--r--src/cmd/go/internal/vcweb/vcstest/vcstest_test.go170
-rw-r--r--src/cmd/go/internal/vcweb/vcweb.go425
-rw-r--r--src/cmd/go/internal/vcweb/vcweb_test.go63
13 files changed, 1813 insertions, 0 deletions
diff --git a/src/cmd/go/internal/vcweb/auth.go b/src/cmd/go/internal/vcweb/auth.go
new file mode 100644
index 0000000..383bf75
--- /dev/null
+++ b/src/cmd/go/internal/vcweb/auth.go
@@ -0,0 +1,108 @@
+// 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 vcweb
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "log"
+ "net/http"
+ "os"
+ "path"
+ "strings"
+)
+
+// authHandler serves requests only if the Basic Auth data sent with the request
+// matches the contents of a ".access" file in the requested directory.
+//
+// For each request, the handler looks for a file named ".access" and parses it
+// as a JSON-serialized accessToken. If the credentials from the request match
+// the accessToken, the file is served normally; otherwise, it is rejected with
+// the StatusCode and Message provided by the token.
+type authHandler struct{}
+
+type accessToken struct {
+ Username, Password string
+ StatusCode int // defaults to 401.
+ Message string
+}
+
+func (h *authHandler) Available() bool { return true }
+
+func (h *authHandler) Handler(dir string, env []string, logger *log.Logger) (http.Handler, error) {
+ fs := http.Dir(dir)
+
+ handler := http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
+ urlPath := req.URL.Path
+ if urlPath != "" && strings.HasPrefix(path.Base(urlPath), ".") {
+ http.Error(w, "filename contains leading dot", http.StatusBadRequest)
+ return
+ }
+
+ f, err := fs.Open(urlPath)
+ if err != nil {
+ if os.IsNotExist(err) {
+ http.NotFound(w, req)
+ } else {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ }
+ return
+ }
+
+ accessDir := urlPath
+ if fi, err := f.Stat(); err == nil && !fi.IsDir() {
+ accessDir = path.Dir(urlPath)
+ }
+ f.Close()
+
+ var accessFile http.File
+ for {
+ var err error
+ accessFile, err = fs.Open(path.Join(accessDir, ".access"))
+ if err == nil {
+ break
+ }
+
+ if !os.IsNotExist(err) {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ if accessDir == "." {
+ http.Error(w, "failed to locate access file", http.StatusInternalServerError)
+ return
+ }
+ accessDir = path.Dir(accessDir)
+ }
+
+ data, err := io.ReadAll(accessFile)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ var token accessToken
+ if err := json.Unmarshal(data, &token); err != nil {
+ logger.Print(err)
+ http.Error(w, "malformed access file", http.StatusInternalServerError)
+ return
+ }
+ if username, password, ok := req.BasicAuth(); !ok || username != token.Username || password != token.Password {
+ code := token.StatusCode
+ if code == 0 {
+ code = http.StatusUnauthorized
+ }
+ if code == http.StatusUnauthorized {
+ w.Header().Add("WWW-Authenticate", fmt.Sprintf("basic realm=%s", accessDir))
+ }
+ http.Error(w, token.Message, code)
+ return
+ }
+
+ http.FileServer(fs).ServeHTTP(w, req)
+ })
+
+ return handler, nil
+}
diff --git a/src/cmd/go/internal/vcweb/bzr.go b/src/cmd/go/internal/vcweb/bzr.go
new file mode 100644
index 0000000..a915fb2
--- /dev/null
+++ b/src/cmd/go/internal/vcweb/bzr.go
@@ -0,0 +1,18 @@
+// Copyright 2022 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 vcweb
+
+import (
+ "log"
+ "net/http"
+)
+
+type bzrHandler struct{}
+
+func (*bzrHandler) Available() bool { return true }
+
+func (*bzrHandler) Handler(dir string, env []string, logger *log.Logger) (http.Handler, error) {
+ return http.FileServer(http.Dir(dir)), nil
+}
diff --git a/src/cmd/go/internal/vcweb/dir.go b/src/cmd/go/internal/vcweb/dir.go
new file mode 100644
index 0000000..2f122f4
--- /dev/null
+++ b/src/cmd/go/internal/vcweb/dir.go
@@ -0,0 +1,19 @@
+// Copyright 2022 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 vcweb
+
+import (
+ "log"
+ "net/http"
+)
+
+// dirHandler is a vcsHandler that serves the raw contents of a directory.
+type dirHandler struct{}
+
+func (*dirHandler) Available() bool { return true }
+
+func (*dirHandler) Handler(dir string, env []string, logger *log.Logger) (http.Handler, error) {
+ return http.FileServer(http.Dir(dir)), nil
+}
diff --git a/src/cmd/go/internal/vcweb/fossil.go b/src/cmd/go/internal/vcweb/fossil.go
new file mode 100644
index 0000000..cc24f2f
--- /dev/null
+++ b/src/cmd/go/internal/vcweb/fossil.go
@@ -0,0 +1,61 @@
+// 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 vcweb
+
+import (
+ "fmt"
+ "log"
+ "net/http"
+ "net/http/cgi"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "sync"
+)
+
+type fossilHandler struct {
+ once sync.Once
+ fossilPath string
+ fossilPathErr error
+}
+
+func (h *fossilHandler) Available() bool {
+ h.once.Do(func() {
+ h.fossilPath, h.fossilPathErr = exec.LookPath("fossil")
+ })
+ return h.fossilPathErr == nil
+}
+
+func (h *fossilHandler) Handler(dir string, env []string, logger *log.Logger) (http.Handler, error) {
+ if !h.Available() {
+ return nil, ServerNotInstalledError{name: "fossil"}
+ }
+
+ name := filepath.Base(dir)
+ db := filepath.Join(dir, name+".fossil")
+
+ cgiPath := db + ".cgi"
+ cgiScript := fmt.Sprintf("#!%s\nrepository: %s\n", h.fossilPath, db)
+ if err := os.WriteFile(cgiPath, []byte(cgiScript), 0755); err != nil {
+ return nil, err
+ }
+
+ handler := http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
+ if _, err := os.Stat(db); err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ ch := &cgi.Handler{
+ Env: env,
+ Logger: logger,
+ Path: h.fossilPath,
+ Args: []string{cgiPath},
+ Dir: dir,
+ }
+ ch.ServeHTTP(w, req)
+ })
+
+ return handler, nil
+}
diff --git a/src/cmd/go/internal/vcweb/git.go b/src/cmd/go/internal/vcweb/git.go
new file mode 100644
index 0000000..d1e0563
--- /dev/null
+++ b/src/cmd/go/internal/vcweb/git.go
@@ -0,0 +1,71 @@
+// 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 vcweb
+
+import (
+ "log"
+ "net/http"
+ "net/http/cgi"
+ "os/exec"
+ "runtime"
+ "slices"
+ "sync"
+)
+
+type gitHandler struct {
+ once sync.Once
+ gitPath string
+ gitPathErr error
+}
+
+func (h *gitHandler) Available() bool {
+ if runtime.GOOS == "plan9" {
+ // The Git command is usually not the real Git on Plan 9.
+ // See https://golang.org/issues/29640.
+ return false
+ }
+ h.once.Do(func() {
+ h.gitPath, h.gitPathErr = exec.LookPath("git")
+ })
+ return h.gitPathErr == nil
+}
+
+func (h *gitHandler) Handler(dir string, env []string, logger *log.Logger) (http.Handler, error) {
+ if !h.Available() {
+ return nil, ServerNotInstalledError{name: "git"}
+ }
+
+ baseEnv := append(slices.Clip(env),
+ "GIT_PROJECT_ROOT="+dir,
+ "GIT_HTTP_EXPORT_ALL=1",
+ )
+
+ handler := http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
+ // The Git client sends the requested Git protocol version as a
+ // "Git-Protocol" HTTP request header, which the CGI host then converts
+ // to an environment variable (HTTP_GIT_PROTOCOL).
+ //
+ // However, versions of Git older that 2.34.0 don't recognize the
+ // HTTP_GIT_PROTOCOL variable, and instead need that value to be set in the
+ // GIT_PROTOCOL variable. We do so here so that vcweb can work reliably
+ // with older Git releases. (As of the time of writing, the Go project's
+ // builders were on Git version 2.30.2.)
+ env := slices.Clip(baseEnv)
+ if p := req.Header.Get("Git-Protocol"); p != "" {
+ env = append(env, "GIT_PROTOCOL="+p)
+ }
+
+ h := &cgi.Handler{
+ Path: h.gitPath,
+ Logger: logger,
+ Args: []string{"http-backend"},
+ Dir: dir,
+ Env: env,
+ }
+ h.ServeHTTP(w, req)
+ })
+
+ return handler, nil
+}
diff --git a/src/cmd/go/internal/vcweb/hg.go b/src/cmd/go/internal/vcweb/hg.go
new file mode 100644
index 0000000..4571277
--- /dev/null
+++ b/src/cmd/go/internal/vcweb/hg.go
@@ -0,0 +1,123 @@
+// Copyright 2022 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 vcweb
+
+import (
+ "bufio"
+ "context"
+ "errors"
+ "io"
+ "log"
+ "net/http"
+ "net/http/httputil"
+ "net/url"
+ "os"
+ "os/exec"
+ "slices"
+ "strings"
+ "sync"
+ "time"
+)
+
+type hgHandler struct {
+ once sync.Once
+ hgPath string
+ hgPathErr error
+}
+
+func (h *hgHandler) Available() bool {
+ h.once.Do(func() {
+ h.hgPath, h.hgPathErr = exec.LookPath("hg")
+ })
+ return h.hgPathErr == nil
+}
+
+func (h *hgHandler) Handler(dir string, env []string, logger *log.Logger) (http.Handler, error) {
+ if !h.Available() {
+ return nil, ServerNotInstalledError{name: "hg"}
+ }
+
+ handler := http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
+ // Mercurial has a CGI server implementation (called hgweb). In theory we
+ // could use that — however, assuming that hgweb is even installed, the
+ // configuration for hgweb varies by Python version (2 vs 3), and we would
+ // rather not go rooting around trying to find the right Python version to
+ // run.
+ //
+ // Instead, we'll take a somewhat more roundabout approach: we assume that
+ // if "hg" works at all then "hg serve" works too, and we'll execute that as
+ // a subprocess, using a reverse proxy to forward the request and response.
+
+ ctx, cancel := context.WithCancel(req.Context())
+ defer cancel()
+
+ cmd := exec.CommandContext(ctx, h.hgPath, "serve", "--port", "0", "--address", "localhost", "--accesslog", os.DevNull, "--name", "vcweb", "--print-url")
+ cmd.Dir = dir
+ cmd.Env = append(slices.Clip(env), "PWD="+dir)
+
+ cmd.Cancel = func() error {
+ err := cmd.Process.Signal(os.Interrupt)
+ if err != nil && !errors.Is(err, os.ErrProcessDone) {
+ err = cmd.Process.Kill()
+ }
+ return err
+ }
+ // This WaitDelay is arbitrary. After 'hg serve' prints its URL, any further
+ // I/O is only for debugging. (The actual output goes through the HTTP URL,
+ // not the standard I/O streams.)
+ cmd.WaitDelay = 10 * time.Second
+
+ stderr := new(strings.Builder)
+ cmd.Stderr = stderr
+
+ stdout, err := cmd.StdoutPipe()
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ if err := cmd.Start(); err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ var wg sync.WaitGroup
+ defer func() {
+ cancel()
+ err := cmd.Wait()
+ if out := strings.TrimSuffix(stderr.String(), "interrupted!\n"); out != "" {
+ logger.Printf("%v: %v\n%s", cmd, err, out)
+ } else {
+ logger.Printf("%v", cmd)
+ }
+ wg.Wait()
+ }()
+
+ r := bufio.NewReader(stdout)
+ line, err := r.ReadString('\n')
+ if err != nil {
+ return
+ }
+ // We have read what should be the server URL. 'hg serve' shouldn't need to
+ // write anything else to stdout, but it's not a big deal if it does anyway.
+ // Keep the stdout pipe open so that 'hg serve' won't get a SIGPIPE, but
+ // actively discard its output so that it won't hang on a blocking write.
+ wg.Add(1)
+ go func() {
+ io.Copy(io.Discard, r)
+ wg.Done()
+ }()
+
+ u, err := url.Parse(strings.TrimSpace(line))
+ if err != nil {
+ logger.Printf("%v: %v", cmd, err)
+ http.Error(w, err.Error(), http.StatusBadGateway)
+ return
+ }
+ logger.Printf("proxying hg request to %s", u)
+ httputil.NewSingleHostReverseProxy(u).ServeHTTP(w, req)
+ })
+
+ return handler, nil
+}
diff --git a/src/cmd/go/internal/vcweb/insecure.go b/src/cmd/go/internal/vcweb/insecure.go
new file mode 100644
index 0000000..1d6af25
--- /dev/null
+++ b/src/cmd/go/internal/vcweb/insecure.go
@@ -0,0 +1,42 @@
+// Copyright 2022 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 vcweb
+
+import (
+ "log"
+ "net/http"
+)
+
+// insecureHandler redirects requests to the same host and path but using the
+// "http" scheme instead of "https".
+type insecureHandler struct{}
+
+func (h *insecureHandler) Available() bool { return true }
+
+func (h *insecureHandler) Handler(dir string, env []string, logger *log.Logger) (http.Handler, error) {
+ // The insecure-redirect handler implementation doesn't depend or dir or env.
+ //
+ // The only effect of the directory is to determine which prefix the caller
+ // will strip from the request before passing it on to this handler.
+ return h, nil
+}
+
+func (h *insecureHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
+ if req.Host == "" && req.URL.Host == "" {
+ http.Error(w, "no Host provided in request", http.StatusBadRequest)
+ return
+ }
+
+ // Note that if the handler is wrapped with http.StripPrefix, the prefix
+ // will remain stripped in the redirected URL, preventing redirect loops
+ // if the scheme is already "http".
+
+ u := *req.URL
+ u.Scheme = "http"
+ u.User = nil
+ u.Host = req.Host
+
+ http.Redirect(w, req, u.String(), http.StatusFound)
+}
diff --git a/src/cmd/go/internal/vcweb/script.go b/src/cmd/go/internal/vcweb/script.go
new file mode 100644
index 0000000..c35b46f
--- /dev/null
+++ b/src/cmd/go/internal/vcweb/script.go
@@ -0,0 +1,345 @@
+// Copyright 2022 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 vcweb
+
+import (
+ "bufio"
+ "bytes"
+ "cmd/go/internal/script"
+ "context"
+ "errors"
+ "fmt"
+ "internal/txtar"
+ "io"
+ "log"
+ "net/http"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "runtime"
+ "strconv"
+ "strings"
+ "time"
+
+ "golang.org/x/mod/module"
+ "golang.org/x/mod/zip"
+)
+
+// newScriptEngine returns a script engine augmented with commands for
+// reproducing version-control repositories by replaying commits.
+func newScriptEngine() *script.Engine {
+ conds := script.DefaultConds()
+
+ interrupt := func(cmd *exec.Cmd) error { return cmd.Process.Signal(os.Interrupt) }
+ gracePeriod := 30 * time.Second // arbitrary
+
+ cmds := script.DefaultCmds()
+ cmds["at"] = scriptAt()
+ cmds["bzr"] = script.Program("bzr", interrupt, gracePeriod)
+ cmds["fossil"] = script.Program("fossil", interrupt, gracePeriod)
+ cmds["git"] = script.Program("git", interrupt, gracePeriod)
+ cmds["hg"] = script.Program("hg", interrupt, gracePeriod)
+ cmds["handle"] = scriptHandle()
+ cmds["modzip"] = scriptModzip()
+ cmds["svnadmin"] = script.Program("svnadmin", interrupt, gracePeriod)
+ cmds["svn"] = script.Program("svn", interrupt, gracePeriod)
+ cmds["unquote"] = scriptUnquote()
+
+ return &script.Engine{
+ Cmds: cmds,
+ Conds: conds,
+ }
+}
+
+// loadScript interprets the given script content using the vcweb script engine.
+// loadScript always returns either a non-nil handler or a non-nil error.
+//
+// The script content must be a txtar archive with a comment containing a script
+// with exactly one "handle" command and zero or more VCS commands to prepare
+// the repository to be served.
+func (s *Server) loadScript(ctx context.Context, logger *log.Logger, scriptPath string, scriptContent []byte, workDir string) (http.Handler, error) {
+ ar := txtar.Parse(scriptContent)
+
+ if err := os.MkdirAll(workDir, 0755); err != nil {
+ return nil, err
+ }
+
+ st, err := s.newState(ctx, workDir)
+ if err != nil {
+ return nil, err
+ }
+ if err := st.ExtractFiles(ar); err != nil {
+ return nil, err
+ }
+
+ scriptName := filepath.Base(scriptPath)
+ scriptLog := new(strings.Builder)
+ err = s.engine.Execute(st, scriptName, bufio.NewReader(bytes.NewReader(ar.Comment)), scriptLog)
+ closeErr := st.CloseAndWait(scriptLog)
+ logger.Printf("%s:", scriptName)
+ io.WriteString(logger.Writer(), scriptLog.String())
+ io.WriteString(logger.Writer(), "\n")
+ if err != nil {
+ return nil, err
+ }
+ if closeErr != nil {
+ return nil, err
+ }
+
+ sc, err := getScriptCtx(st)
+ if err != nil {
+ return nil, err
+ }
+ if sc.handler == nil {
+ return nil, errors.New("script completed without setting handler")
+ }
+ return sc.handler, nil
+}
+
+// newState returns a new script.State for executing scripts in workDir.
+func (s *Server) newState(ctx context.Context, workDir string) (*script.State, error) {
+ ctx = &scriptCtx{
+ Context: ctx,
+ server: s,
+ }
+
+ st, err := script.NewState(ctx, workDir, s.env)
+ if err != nil {
+ return nil, err
+ }
+ return st, nil
+}
+
+// scriptEnviron returns a new environment that attempts to provide predictable
+// behavior for the supported version-control tools.
+func scriptEnviron(homeDir string) []string {
+ env := []string{
+ "USER=gopher",
+ homeEnvName() + "=" + homeDir,
+ "GIT_CONFIG_NOSYSTEM=1",
+ "HGRCPATH=" + filepath.Join(homeDir, ".hgrc"),
+ "HGENCODING=utf-8",
+ }
+ // Preserve additional environment variables that may be needed by VCS tools.
+ for _, k := range []string{
+ pathEnvName(),
+ tempEnvName(),
+ "SYSTEMROOT", // must be preserved on Windows to find DLLs; golang.org/issue/25210
+ "WINDIR", // must be preserved on Windows to be able to run PowerShell command; golang.org/issue/30711
+ "ComSpec", // must be preserved on Windows to be able to run Batch files; golang.org/issue/56555
+ "DYLD_LIBRARY_PATH", // must be preserved on macOS systems to find shared libraries
+ "LD_LIBRARY_PATH", // must be preserved on Unix systems to find shared libraries
+ "LIBRARY_PATH", // allow override of non-standard static library paths
+ "PYTHONPATH", // may be needed by hg to find imported modules
+ } {
+ if v, ok := os.LookupEnv(k); ok {
+ env = append(env, k+"="+v)
+ }
+ }
+
+ if os.Getenv("GO_BUILDER_NAME") != "" || os.Getenv("GIT_TRACE_CURL") == "1" {
+ // To help diagnose https://go.dev/issue/52545,
+ // enable tracing for Git HTTPS requests.
+ env = append(env,
+ "GIT_TRACE_CURL=1",
+ "GIT_TRACE_CURL_NO_DATA=1",
+ "GIT_REDACT_COOKIES=o,SSO,GSSO_Uberproxy")
+ }
+
+ return env
+}
+
+// homeEnvName returns the environment variable used by os.UserHomeDir
+// to locate the user's home directory.
+func homeEnvName() string {
+ switch runtime.GOOS {
+ case "windows":
+ return "USERPROFILE"
+ case "plan9":
+ return "home"
+ default:
+ return "HOME"
+ }
+}
+
+// tempEnvName returns the environment variable used by os.TempDir
+// to locate the default directory for temporary files.
+func tempEnvName() string {
+ switch runtime.GOOS {
+ case "windows":
+ return "TMP"
+ case "plan9":
+ return "TMPDIR" // actually plan 9 doesn't have one at all but this is fine
+ default:
+ return "TMPDIR"
+ }
+}
+
+// pathEnvName returns the environment variable used by exec.LookPath to
+// identify directories to search for executables.
+func pathEnvName() string {
+ switch runtime.GOOS {
+ case "plan9":
+ return "path"
+ default:
+ return "PATH"
+ }
+}
+
+// A scriptCtx is a context.Context that stores additional state for script
+// commands.
+type scriptCtx struct {
+ context.Context
+ server *Server
+ commitTime time.Time
+ handlerName string
+ handler http.Handler
+}
+
+// scriptCtxKey is the key associating the *scriptCtx in a script's Context..
+type scriptCtxKey struct{}
+
+func (sc *scriptCtx) Value(key any) any {
+ if key == (scriptCtxKey{}) {
+ return sc
+ }
+ return sc.Context.Value(key)
+}
+
+func getScriptCtx(st *script.State) (*scriptCtx, error) {
+ sc, ok := st.Context().Value(scriptCtxKey{}).(*scriptCtx)
+ if !ok {
+ return nil, errors.New("scriptCtx not found in State.Context")
+ }
+ return sc, nil
+}
+
+func scriptAt() script.Cmd {
+ return script.Command(
+ script.CmdUsage{
+ Summary: "set the current commit time for all version control systems",
+ Args: "time",
+ Detail: []string{
+ "The argument must be an absolute timestamp in RFC3339 format.",
+ },
+ },
+ func(st *script.State, args ...string) (script.WaitFunc, error) {
+ if len(args) != 1 {
+ return nil, script.ErrUsage
+ }
+
+ sc, err := getScriptCtx(st)
+ if err != nil {
+ return nil, err
+ }
+
+ sc.commitTime, err = time.ParseInLocation(time.RFC3339, args[0], time.UTC)
+ if err == nil {
+ st.Setenv("GIT_COMMITTER_DATE", args[0])
+ st.Setenv("GIT_AUTHOR_DATE", args[0])
+ }
+ return nil, err
+ })
+}
+
+func scriptHandle() script.Cmd {
+ return script.Command(
+ script.CmdUsage{
+ Summary: "set the HTTP handler that will serve the script's output",
+ Args: "handler [dir]",
+ Detail: []string{
+ "The handler will be passed the script's current working directory and environment as arguments.",
+ "Valid handlers include 'dir' (for general http.Dir serving), 'bzr', 'fossil', 'git', and 'hg'",
+ },
+ },
+ func(st *script.State, args ...string) (script.WaitFunc, error) {
+ if len(args) == 0 || len(args) > 2 {
+ return nil, script.ErrUsage
+ }
+
+ sc, err := getScriptCtx(st)
+ if err != nil {
+ return nil, err
+ }
+
+ if sc.handler != nil {
+ return nil, fmt.Errorf("server handler already set to %s", sc.handlerName)
+ }
+
+ name := args[0]
+ h, ok := sc.server.vcsHandlers[name]
+ if !ok {
+ return nil, fmt.Errorf("unrecognized VCS %q", name)
+ }
+ sc.handlerName = name
+ if !h.Available() {
+ return nil, ServerNotInstalledError{name}
+ }
+
+ dir := st.Getwd()
+ if len(args) >= 2 {
+ dir = st.Path(args[1])
+ }
+ sc.handler, err = h.Handler(dir, st.Environ(), sc.server.logger)
+ return nil, err
+ })
+}
+
+func scriptModzip() script.Cmd {
+ return script.Command(
+ script.CmdUsage{
+ Summary: "create a Go module zip file from a directory",
+ Args: "zipfile path@version dir",
+ },
+ func(st *script.State, args ...string) (wait script.WaitFunc, err error) {
+ if len(args) != 3 {
+ return nil, script.ErrUsage
+ }
+ zipPath := st.Path(args[0])
+ mPath, version, ok := strings.Cut(args[1], "@")
+ if !ok {
+ return nil, script.ErrUsage
+ }
+ dir := st.Path(args[2])
+
+ if err := os.MkdirAll(filepath.Dir(zipPath), 0755); err != nil {
+ return nil, err
+ }
+ f, err := os.Create(zipPath)
+ if err != nil {
+ return nil, err
+ }
+ defer func() {
+ if closeErr := f.Close(); err == nil {
+ err = closeErr
+ }
+ }()
+
+ return nil, zip.CreateFromDir(f, module.Version{Path: mPath, Version: version}, dir)
+ })
+}
+
+func scriptUnquote() script.Cmd {
+ return script.Command(
+ script.CmdUsage{
+ Summary: "unquote the argument as a Go string",
+ Args: "string",
+ },
+ func(st *script.State, args ...string) (script.WaitFunc, error) {
+ if len(args) != 1 {
+ return nil, script.ErrUsage
+ }
+
+ s, err := strconv.Unquote(`"` + args[0] + `"`)
+ if err != nil {
+ return nil, err
+ }
+
+ wait := func(*script.State) (stdout, stderr string, err error) {
+ return s, "", nil
+ }
+ return wait, nil
+ })
+}
diff --git a/src/cmd/go/internal/vcweb/svn.go b/src/cmd/go/internal/vcweb/svn.go
new file mode 100644
index 0000000..60222f1
--- /dev/null
+++ b/src/cmd/go/internal/vcweb/svn.go
@@ -0,0 +1,199 @@
+// Copyright 2022 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 vcweb
+
+import (
+ "io"
+ "log"
+ "net"
+ "net/http"
+ "os/exec"
+ "strings"
+ "sync"
+)
+
+// An svnHandler serves requests for Subversion repos.
+//
+// Unlike the other vcweb handlers, svnHandler does not serve the Subversion
+// protocol directly over the HTTP connection. Instead, it opens a separate port
+// that serves the (non-HTTP) 'svn' protocol. The test binary can retrieve the
+// URL for that port by sending an HTTP request with the query parameter
+// "vcwebsvn=1".
+//
+// We take this approach because the 'svn' protocol is implemented by a
+// lightweight 'svnserve' binary that is usually packaged along with the 'svn'
+// client binary, whereas only known implementation of the Subversion HTTP
+// protocol is the mod_dav_svn apache2 module. Apache2 has a lot of dependencies
+// and also seems to rely on global configuration via well-known file paths, so
+// implementing a hermetic test using apache2 would require the test to run in a
+// complicated container environment, which wouldn't be nearly as
+// straightforward for Go contributors to set up and test against on their local
+// machine.
+type svnHandler struct {
+ svnRoot string // a directory containing all svn repos to be served
+ logger *log.Logger
+
+ pathOnce sync.Once
+ svnservePath string // the path to the 'svnserve' executable
+ svnserveErr error
+
+ listenOnce sync.Once
+ s chan *svnState // 1-buffered
+}
+
+// An svnState describes the state of a port serving the 'svn://' protocol.
+type svnState struct {
+ listener net.Listener
+ listenErr error
+ conns map[net.Conn]struct{}
+ closing bool
+ done chan struct{}
+}
+
+func (h *svnHandler) Available() bool {
+ h.pathOnce.Do(func() {
+ h.svnservePath, h.svnserveErr = exec.LookPath("svnserve")
+ })
+ return h.svnserveErr == nil
+}
+
+// Handler returns an http.Handler that checks for the "vcwebsvn" query
+// parameter and then serves the 'svn://' URL for the repository at the
+// requested path.
+// The HTTP client is expected to read that URL and pass it to the 'svn' client.
+func (h *svnHandler) Handler(dir string, env []string, logger *log.Logger) (http.Handler, error) {
+ if !h.Available() {
+ return nil, ServerNotInstalledError{name: "svn"}
+ }
+
+ // Go ahead and start the listener now, so that if it fails (for example, due
+ // to port exhaustion) we can return an error from the Handler method instead
+ // of serving an error for each individual HTTP request.
+ h.listenOnce.Do(func() {
+ h.s = make(chan *svnState, 1)
+ l, err := net.Listen("tcp", "localhost:0")
+ done := make(chan struct{})
+
+ h.s <- &svnState{
+ listener: l,
+ listenErr: err,
+ conns: map[net.Conn]struct{}{},
+ done: done,
+ }
+ if err != nil {
+ close(done)
+ return
+ }
+
+ h.logger.Printf("serving svn on svn://%v", l.Addr())
+
+ go func() {
+ for {
+ c, err := l.Accept()
+
+ s := <-h.s
+ if err != nil {
+ s.listenErr = err
+ if len(s.conns) == 0 {
+ close(s.done)
+ }
+ h.s <- s
+ return
+ }
+ if s.closing {
+ c.Close()
+ } else {
+ s.conns[c] = struct{}{}
+ go h.serve(c)
+ }
+ h.s <- s
+ }
+ }()
+ })
+
+ s := <-h.s
+ addr := ""
+ if s.listener != nil {
+ addr = s.listener.Addr().String()
+ }
+ err := s.listenErr
+ h.s <- s
+ if err != nil {
+ return nil, err
+ }
+
+ handler := http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
+ if req.FormValue("vcwebsvn") != "" {
+ w.Header().Add("Content-Type", "text/plain; charset=UTF-8")
+ io.WriteString(w, "svn://"+addr+"\n")
+ return
+ }
+ http.NotFound(w, req)
+ })
+
+ return handler, nil
+}
+
+// serve serves a single 'svn://' connection on c.
+func (h *svnHandler) serve(c net.Conn) {
+ defer func() {
+ c.Close()
+
+ s := <-h.s
+ delete(s.conns, c)
+ if len(s.conns) == 0 && s.listenErr != nil {
+ close(s.done)
+ }
+ h.s <- s
+ }()
+
+ // The "--inetd" flag causes svnserve to speak the 'svn' protocol over its
+ // stdin and stdout streams as if invoked by the Unix "inetd" service.
+ // We aren't using inetd, but we are implementing essentially the same
+ // approach: using a host process to listen for connections and spawn
+ // subprocesses to serve them.
+ cmd := exec.Command(h.svnservePath, "--read-only", "--root="+h.svnRoot, "--inetd")
+ cmd.Stdin = c
+ cmd.Stdout = c
+ stderr := new(strings.Builder)
+ cmd.Stderr = stderr
+ err := cmd.Run()
+
+ var errFrag any = "ok"
+ if err != nil {
+ errFrag = err
+ }
+ stderrFrag := ""
+ if stderr.Len() > 0 {
+ stderrFrag = "\n" + stderr.String()
+ }
+ h.logger.Printf("%v: %s%s", cmd, errFrag, stderrFrag)
+}
+
+// Close stops accepting new svn:// connections and terminates the existing
+// ones, then waits for the 'svnserve' subprocesses to complete.
+func (h *svnHandler) Close() error {
+ h.listenOnce.Do(func() {})
+ if h.s == nil {
+ return nil
+ }
+
+ var err error
+ s := <-h.s
+ s.closing = true
+ if s.listener == nil {
+ err = s.listenErr
+ } else {
+ err = s.listener.Close()
+ }
+ for c := range s.conns {
+ c.Close()
+ }
+ done := s.done
+ h.s <- s
+
+ <-done
+ return err
+}
diff --git a/src/cmd/go/internal/vcweb/vcstest/vcstest.go b/src/cmd/go/internal/vcweb/vcstest/vcstest.go
new file mode 100644
index 0000000..d460259
--- /dev/null
+++ b/src/cmd/go/internal/vcweb/vcstest/vcstest.go
@@ -0,0 +1,169 @@
+// Copyright 2022 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 vcstest serves the repository scripts in cmd/go/testdata/vcstest
+// using the [vcweb] script engine.
+package vcstest
+
+import (
+ "cmd/go/internal/vcs"
+ "cmd/go/internal/vcweb"
+ "cmd/go/internal/web"
+ "crypto/tls"
+ "crypto/x509"
+ "encoding/pem"
+ "fmt"
+ "internal/testenv"
+ "io"
+ "log"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+var Hosts = []string{
+ "vcs-test.golang.org",
+}
+
+type Server struct {
+ vcweb *vcweb.Server
+ workDir string
+ HTTP *httptest.Server
+ HTTPS *httptest.Server
+}
+
+// NewServer returns a new test-local vcweb server that serves VCS requests
+// for modules with paths that begin with "vcs-test.golang.org" using the
+// scripts in cmd/go/testdata/vcstest.
+func NewServer() (srv *Server, err error) {
+ if vcs.VCSTestRepoURL != "" {
+ panic("vcs URL hooks already set")
+ }
+
+ scriptDir := filepath.Join(testenv.GOROOT(nil), "src/cmd/go/testdata/vcstest")
+
+ workDir, err := os.MkdirTemp("", "vcstest")
+ if err != nil {
+ return nil, err
+ }
+ defer func() {
+ if err != nil {
+ os.RemoveAll(workDir)
+ }
+ }()
+
+ logger := log.Default()
+ if !testing.Verbose() {
+ logger = log.New(io.Discard, "", log.LstdFlags)
+ }
+ handler, err := vcweb.NewServer(scriptDir, workDir, logger)
+ if err != nil {
+ return nil, err
+ }
+ defer func() {
+ if err != nil {
+ handler.Close()
+ }
+ }()
+
+ srvHTTP := httptest.NewServer(handler)
+ httpURL, err := url.Parse(srvHTTP.URL)
+ if err != nil {
+ return nil, err
+ }
+ defer func() {
+ if err != nil {
+ srvHTTP.Close()
+ }
+ }()
+
+ srvHTTPS := httptest.NewTLSServer(handler)
+ httpsURL, err := url.Parse(srvHTTPS.URL)
+ if err != nil {
+ return nil, err
+ }
+ defer func() {
+ if err != nil {
+ srvHTTPS.Close()
+ }
+ }()
+
+ srv = &Server{
+ vcweb: handler,
+ workDir: workDir,
+ HTTP: srvHTTP,
+ HTTPS: srvHTTPS,
+ }
+ vcs.VCSTestRepoURL = srv.HTTP.URL
+ vcs.VCSTestHosts = Hosts
+
+ var interceptors []web.Interceptor
+ for _, host := range Hosts {
+ interceptors = append(interceptors,
+ web.Interceptor{Scheme: "http", FromHost: host, ToHost: httpURL.Host, Client: srv.HTTP.Client()},
+ web.Interceptor{Scheme: "https", FromHost: host, ToHost: httpsURL.Host, Client: srv.HTTPS.Client()})
+ }
+ web.EnableTestHooks(interceptors)
+
+ fmt.Fprintln(os.Stderr, "vcs-test.golang.org rerouted to "+srv.HTTP.URL)
+ fmt.Fprintln(os.Stderr, "https://vcs-test.golang.org rerouted to "+srv.HTTPS.URL)
+
+ return srv, nil
+}
+
+func (srv *Server) Close() error {
+ if vcs.VCSTestRepoURL != srv.HTTP.URL {
+ panic("vcs URL hooks modified before Close")
+ }
+ vcs.VCSTestRepoURL = ""
+ vcs.VCSTestHosts = nil
+ web.DisableTestHooks()
+
+ srv.HTTP.Close()
+ srv.HTTPS.Close()
+ err := srv.vcweb.Close()
+ if rmErr := os.RemoveAll(srv.workDir); err == nil {
+ err = rmErr
+ }
+ return err
+}
+
+func (srv *Server) WriteCertificateFile() (string, error) {
+ b := pem.EncodeToMemory(&pem.Block{
+ Type: "CERTIFICATE",
+ Bytes: srv.HTTPS.Certificate().Raw,
+ })
+
+ filename := filepath.Join(srv.workDir, "cert.pem")
+ if err := os.WriteFile(filename, b, 0644); err != nil {
+ return "", err
+ }
+ return filename, nil
+}
+
+// TLSClient returns an http.Client that can talk to the httptest.Server
+// whose certificate is written to the given file path.
+func TLSClient(certFile string) (*http.Client, error) {
+ client := &http.Client{
+ Transport: http.DefaultTransport.(*http.Transport).Clone(),
+ }
+
+ pemBytes, err := os.ReadFile(certFile)
+ if err != nil {
+ return nil, err
+ }
+
+ certpool := x509.NewCertPool()
+ if !certpool.AppendCertsFromPEM(pemBytes) {
+ return nil, fmt.Errorf("no certificates found in %s", certFile)
+ }
+ client.Transport.(*http.Transport).TLSClientConfig = &tls.Config{
+ RootCAs: certpool,
+ }
+
+ return client, nil
+}
diff --git a/src/cmd/go/internal/vcweb/vcstest/vcstest_test.go b/src/cmd/go/internal/vcweb/vcstest/vcstest_test.go
new file mode 100644
index 0000000..4a6d600
--- /dev/null
+++ b/src/cmd/go/internal/vcweb/vcstest/vcstest_test.go
@@ -0,0 +1,170 @@
+// Copyright 2022 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 vcstest_test
+
+import (
+ "cmd/go/internal/vcweb"
+ "errors"
+ "flag"
+ "fmt"
+ "io"
+ "io/fs"
+ "log"
+ "net"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+)
+
+var (
+ dir = flag.String("dir", "../../../testdata/vcstest", "directory containing scripts to serve")
+ host = flag.String("host", "localhost", "hostname on which to serve HTTP")
+ port = flag.Int("port", -1, "port on which to serve HTTP; if nonnegative, skips running tests")
+)
+
+func TestMain(m *testing.M) {
+ flag.Parse()
+
+ if *port >= 0 {
+ err := serveStandalone(*host, *port)
+ if err != nil {
+ log.Fatal(err)
+ }
+ os.Exit(0)
+ }
+
+ m.Run()
+}
+
+// serveStandalone serves the vcweb testdata in a standalone HTTP server.
+func serveStandalone(host string, port int) (err error) {
+ scriptDir, err := filepath.Abs(*dir)
+ if err != nil {
+ return err
+ }
+ work, err := os.MkdirTemp("", "vcweb")
+ if err != nil {
+ return err
+ }
+ defer func() {
+ if rmErr := os.RemoveAll(work); err == nil {
+ err = rmErr
+ }
+ }()
+
+ log.Printf("running scripts in %s", work)
+
+ v, err := vcweb.NewServer(scriptDir, work, log.Default())
+ if err != nil {
+ return err
+ }
+
+ l, err := net.Listen("tcp", fmt.Sprintf("%s:%d", host, port))
+ if err != nil {
+ return err
+ }
+ log.Printf("serving on http://%s:%d/", host, l.Addr().(*net.TCPAddr).Port)
+
+ return http.Serve(l, v)
+}
+
+// TestScripts verifies that the VCS setup scripts in cmd/go/testdata/vcstest
+// run successfully.
+func TestScripts(t *testing.T) {
+ scriptDir, err := filepath.Abs(*dir)
+ if err != nil {
+ t.Fatal(err)
+ }
+ s, err := vcweb.NewServer(scriptDir, t.TempDir(), log.Default())
+ if err != nil {
+ t.Fatal(err)
+ }
+ srv := httptest.NewServer(s)
+
+ // To check for data races in the handler, run the root handler to produce an
+ // overview of the script status at an arbitrary point during the test.
+ // (We ignore the output because the expected failure mode is a friendly stack
+ // dump from the race detector.)
+ t.Run("overview", func(t *testing.T) {
+ t.Parallel()
+
+ time.Sleep(1 * time.Millisecond) // Give the other handlers time to race.
+
+ resp, err := http.Get(srv.URL)
+ if err == nil {
+ io.Copy(io.Discard, resp.Body)
+ resp.Body.Close()
+ } else {
+ t.Error(err)
+ }
+ })
+
+ t.Cleanup(func() {
+ // The subtests spawned by WalkDir run in parallel. When they complete, this
+ // Cleanup callback will run. At that point we fetch the root URL (which
+ // contains a status page), both to test that the root handler runs without
+ // crashing and to display a nice summary of the server's view of the test
+ // coverage.
+ resp, err := http.Get(srv.URL)
+ if err == nil {
+ var body []byte
+ body, err = io.ReadAll(resp.Body)
+ if err == nil && testing.Verbose() {
+ t.Logf("GET %s:\n%s", srv.URL, body)
+ }
+ resp.Body.Close()
+ }
+ if err != nil {
+ t.Error(err)
+ }
+
+ srv.Close()
+ })
+
+ err = filepath.WalkDir(scriptDir, func(path string, d fs.DirEntry, err error) error {
+ if err != nil || d.IsDir() {
+ return err
+ }
+
+ rel, err := filepath.Rel(scriptDir, path)
+ if err != nil {
+ return err
+ }
+ if rel == "README" {
+ return nil
+ }
+
+ t.Run(filepath.ToSlash(rel), func(t *testing.T) {
+ t.Parallel()
+
+ buf := new(strings.Builder)
+ logger := log.New(buf, "", log.LstdFlags)
+ // Load the script but don't try to serve the results:
+ // different VCS tools have different handler protocols,
+ // and the tests that actually use these repos will ensure
+ // that they are served correctly as a side effect anyway.
+ err := s.HandleScript(rel, logger, func(http.Handler) {})
+ if buf.Len() > 0 {
+ t.Log(buf)
+ }
+ if err != nil {
+ if notInstalled := (vcweb.ServerNotInstalledError{}); errors.As(err, &notInstalled) || errors.Is(err, exec.ErrNotFound) {
+ t.Skip(err)
+ }
+ t.Error(err)
+ }
+ })
+ return nil
+ })
+
+ if err != nil {
+ t.Error(err)
+ }
+}
diff --git a/src/cmd/go/internal/vcweb/vcweb.go b/src/cmd/go/internal/vcweb/vcweb.go
new file mode 100644
index 0000000..f748b34
--- /dev/null
+++ b/src/cmd/go/internal/vcweb/vcweb.go
@@ -0,0 +1,425 @@
+// Copyright 2022 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 vcweb serves version control repos for testing the go command.
+//
+// It is loosely derived from golang.org/x/build/vcs-test/vcweb,
+// which ran as a service hosted at vcs-test.golang.org.
+//
+// When a repository URL is first requested, the vcweb [Server] dynamically
+// regenerates the repository using a script interpreted by a [script.Engine].
+// The script produces the server's contents for a corresponding root URL and
+// all subdirectories of that URL, which are then cached: subsequent requests
+// for any URL generated by the script will serve the script's previous output
+// until the script is modified.
+//
+// The script engine includes all of the engine's default commands and
+// conditions, as well as commands for each supported VCS binary (bzr, fossil,
+// git, hg, and svn), a "handle" command that informs the script which protocol
+// or handler to use to serve the request, and utilities "at" (which sets
+// environment variables for Git timestamps) and "unquote" (which unquotes its
+// argument as if it were a Go string literal).
+//
+// The server's "/" endpoint provides a summary of the available scripts,
+// and "/help" provides documentation for the script environment.
+//
+// To run a standalone server based on the vcweb engine, use:
+//
+// go test cmd/go/internal/vcweb/vcstest -v --port=0
+package vcweb
+
+import (
+ "bufio"
+ "cmd/go/internal/script"
+ "context"
+ "crypto/sha256"
+ "errors"
+ "fmt"
+ "io"
+ "io/fs"
+ "log"
+ "net/http"
+ "os"
+ "os/exec"
+ "path"
+ "path/filepath"
+ "runtime/debug"
+ "strings"
+ "sync"
+ "text/tabwriter"
+ "time"
+)
+
+// A Server serves cached, dynamically-generated version control repositories.
+type Server struct {
+ env []string
+ logger *log.Logger
+
+ scriptDir string
+ workDir string
+ homeDir string // $workdir/home
+ engine *script.Engine
+
+ scriptCache sync.Map // script path → *scriptResult
+
+ vcsHandlers map[string]vcsHandler
+}
+
+// A vcsHandler serves repositories over HTTP for a known version-control tool.
+type vcsHandler interface {
+ Available() bool
+ Handler(dir string, env []string, logger *log.Logger) (http.Handler, error)
+}
+
+// A scriptResult describes the cached result of executing a vcweb script.
+type scriptResult struct {
+ mu sync.RWMutex
+
+ hash [sha256.Size]byte // hash of the script file, for cache invalidation
+ hashTime time.Time // timestamp at which the script was run, for diagnostics
+
+ handler http.Handler // HTTP handler configured by the script
+ err error // error from executing the script, if any
+}
+
+// NewServer returns a Server that generates and serves repositories in workDir
+// using the scripts found in scriptDir and its subdirectories.
+//
+// A request for the path /foo/bar/baz will be handled by the first script along
+// that path that exists: $scriptDir/foo.txt, $scriptDir/foo/bar.txt, or
+// $scriptDir/foo/bar/baz.txt.
+func NewServer(scriptDir, workDir string, logger *log.Logger) (*Server, error) {
+ if scriptDir == "" {
+ panic("vcweb.NewServer: scriptDir is required")
+ }
+ var err error
+ scriptDir, err = filepath.Abs(scriptDir)
+ if err != nil {
+ return nil, err
+ }
+
+ if workDir == "" {
+ workDir, err = os.MkdirTemp("", "vcweb-*")
+ if err != nil {
+ return nil, err
+ }
+ logger.Printf("vcweb work directory: %s", workDir)
+ } else {
+ workDir, err = filepath.Abs(workDir)
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ homeDir := filepath.Join(workDir, "home")
+ if err := os.MkdirAll(homeDir, 0755); err != nil {
+ return nil, err
+ }
+
+ env := scriptEnviron(homeDir)
+
+ s := &Server{
+ env: env,
+ logger: logger,
+ scriptDir: scriptDir,
+ workDir: workDir,
+ homeDir: homeDir,
+ engine: newScriptEngine(),
+ vcsHandlers: map[string]vcsHandler{
+ "auth": new(authHandler),
+ "dir": new(dirHandler),
+ "bzr": new(bzrHandler),
+ "fossil": new(fossilHandler),
+ "git": new(gitHandler),
+ "hg": new(hgHandler),
+ "insecure": new(insecureHandler),
+ "svn": &svnHandler{svnRoot: workDir, logger: logger},
+ },
+ }
+
+ if err := os.WriteFile(filepath.Join(s.homeDir, ".gitconfig"), []byte(gitConfig), 0644); err != nil {
+ return nil, err
+ }
+ gitConfigDir := filepath.Join(s.homeDir, ".config", "git")
+ if err := os.MkdirAll(gitConfigDir, 0755); err != nil {
+ return nil, err
+ }
+ if err := os.WriteFile(filepath.Join(gitConfigDir, "ignore"), []byte(""), 0644); err != nil {
+ return nil, err
+ }
+
+ if err := os.WriteFile(filepath.Join(s.homeDir, ".hgrc"), []byte(hgrc), 0644); err != nil {
+ return nil, err
+ }
+
+ return s, nil
+}
+
+func (s *Server) Close() error {
+ var firstErr error
+ for _, h := range s.vcsHandlers {
+ if c, ok := h.(io.Closer); ok {
+ if closeErr := c.Close(); firstErr == nil {
+ firstErr = closeErr
+ }
+ }
+ }
+ return firstErr
+}
+
+// gitConfig contains a ~/.gitconfg file that attempts to provide
+// deterministic, platform-agnostic behavior for the 'git' command.
+var gitConfig = `
+[user]
+ name = Go Gopher
+ email = gopher@golang.org
+[init]
+ defaultBranch = main
+[core]
+ eol = lf
+[gui]
+ encoding = utf-8
+`[1:]
+
+// hgrc contains a ~/.hgrc file that attempts to provide
+// deterministic, platform-agnostic behavior for the 'hg' command.
+var hgrc = `
+[ui]
+username=Go Gopher <gopher@golang.org>
+[phases]
+new-commit=public
+[extensions]
+convert=
+`[1:]
+
+// ServeHTTP implements [http.Handler] for version-control repositories.
+func (s *Server) ServeHTTP(w http.ResponseWriter, req *http.Request) {
+ s.logger.Printf("serving %s", req.URL)
+
+ defer func() {
+ if v := recover(); v != nil {
+ debug.PrintStack()
+ s.logger.Fatal(v)
+ }
+ }()
+
+ urlPath := req.URL.Path
+ if !strings.HasPrefix(urlPath, "/") {
+ urlPath = "/" + urlPath
+ }
+ clean := path.Clean(urlPath)[1:]
+ if clean == "" {
+ s.overview(w, req)
+ return
+ }
+ if clean == "help" {
+ s.help(w, req)
+ return
+ }
+
+ // Locate the script that generates the requested path.
+ // We follow directories all the way to the end, then look for a ".txt" file
+ // matching the first component that doesn't exist. That guarantees
+ // uniqueness: if a path exists as a directory, then it cannot exist as a
+ // ".txt" script (because the search would ignore that file).
+ scriptPath := "."
+ for _, part := range strings.Split(clean, "/") {
+ scriptPath = filepath.Join(scriptPath, part)
+ dir := filepath.Join(s.scriptDir, scriptPath)
+ if _, err := os.Stat(dir); err != nil {
+ if !os.IsNotExist(err) {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ // scriptPath does not exist as a directory, so it either is the script
+ // location or the script doesn't exist.
+ break
+ }
+ }
+ scriptPath += ".txt"
+
+ err := s.HandleScript(scriptPath, s.logger, func(handler http.Handler) {
+ handler.ServeHTTP(w, req)
+ })
+ if err != nil {
+ s.logger.Print(err)
+ if notFound := (ScriptNotFoundError{}); errors.As(err, &notFound) {
+ http.NotFound(w, req)
+ } else if notInstalled := (ServerNotInstalledError{}); errors.As(err, &notInstalled) || errors.Is(err, exec.ErrNotFound) {
+ http.Error(w, err.Error(), http.StatusNotImplemented)
+ } else {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ }
+ }
+}
+
+// A ScriptNotFoundError indicates that the requested script file does not exist.
+// (It typically wraps a "stat" error for the script file.)
+type ScriptNotFoundError struct{ err error }
+
+func (e ScriptNotFoundError) Error() string { return e.err.Error() }
+func (e ScriptNotFoundError) Unwrap() error { return e.err }
+
+// A ServerNotInstalledError indicates that the server binary required for the
+// indicated VCS does not exist.
+type ServerNotInstalledError struct{ name string }
+
+func (v ServerNotInstalledError) Error() string {
+ return fmt.Sprintf("server for %#q VCS is not installed", v.name)
+}
+
+// HandleScript ensures that the script at scriptRelPath has been evaluated
+// with its current contents.
+//
+// If the script completed successfully, HandleScript invokes f on the handler
+// with the script's result still read-locked, and waits for it to return. (That
+// ensures that cache invalidation does not race with an in-flight handler.)
+//
+// Otherwise, HandleScript returns the (cached) error from executing the script.
+func (s *Server) HandleScript(scriptRelPath string, logger *log.Logger, f func(http.Handler)) error {
+ ri, ok := s.scriptCache.Load(scriptRelPath)
+ if !ok {
+ ri, _ = s.scriptCache.LoadOrStore(scriptRelPath, new(scriptResult))
+ }
+ r := ri.(*scriptResult)
+
+ relDir := strings.TrimSuffix(scriptRelPath, filepath.Ext(scriptRelPath))
+ workDir := filepath.Join(s.workDir, relDir)
+ prefix := path.Join("/", filepath.ToSlash(relDir))
+
+ r.mu.RLock()
+ defer r.mu.RUnlock()
+ for {
+ // For efficiency, we cache the script's output (in the work directory)
+ // across invocations. However, to allow for rapid iteration, we hash the
+ // script's contents and regenerate its output if the contents change.
+ //
+ // That way, one can use 'go run main.go' in this directory to stand up a
+ // server and see the output of the test script in order to fine-tune it.
+ content, err := os.ReadFile(filepath.Join(s.scriptDir, scriptRelPath))
+ if err != nil {
+ if !os.IsNotExist(err) {
+ return err
+ }
+ return ScriptNotFoundError{err}
+ }
+
+ hash := sha256.Sum256(content)
+ if prevHash := r.hash; prevHash != hash {
+ // The script's hash has changed, so regenerate its output.
+ func() {
+ r.mu.RUnlock()
+ r.mu.Lock()
+ defer func() {
+ r.mu.Unlock()
+ r.mu.RLock()
+ }()
+ if r.hash != prevHash {
+ // The cached result changed while we were waiting on the lock.
+ // It may have been updated to our hash or something even newer,
+ // so don't overwrite it.
+ return
+ }
+
+ r.hash = hash
+ r.hashTime = time.Now()
+ r.handler, r.err = nil, nil
+
+ if err := os.RemoveAll(workDir); err != nil {
+ r.err = err
+ return
+ }
+
+ // Note: we use context.Background here instead of req.Context() so that we
+ // don't cache a spurious error (and lose work) if the request is canceled
+ // while the script is still running.
+ scriptHandler, err := s.loadScript(context.Background(), logger, scriptRelPath, content, workDir)
+ if err != nil {
+ r.err = err
+ return
+ }
+ r.handler = http.StripPrefix(prefix, scriptHandler)
+ }()
+ }
+
+ if r.hash != hash {
+ continue // Raced with an update from another handler; try again.
+ }
+
+ if r.err != nil {
+ return r.err
+ }
+ f(r.handler)
+ return nil
+ }
+}
+
+// overview serves an HTML summary of the status of the scripts in the server's
+// script directory.
+func (s *Server) overview(w http.ResponseWriter, r *http.Request) {
+ fmt.Fprintf(w, "<html>\n")
+ fmt.Fprintf(w, "<title>vcweb</title>\n<pre>\n")
+ fmt.Fprintf(w, "<b>vcweb</b>\n\n")
+ fmt.Fprintf(w, "This server serves various version control repos for testing the go command.\n\n")
+ fmt.Fprintf(w, "For an overview of the script language, see <a href=\"/help\">/help</a>.\n\n")
+
+ fmt.Fprintf(w, "<b>cache</b>\n")
+
+ tw := tabwriter.NewWriter(w, 1, 8, 1, '\t', 0)
+ err := filepath.WalkDir(s.scriptDir, func(path string, d fs.DirEntry, err error) error {
+ if err != nil {
+ return err
+ }
+ if filepath.Ext(path) != ".txt" {
+ return nil
+ }
+
+ rel, err := filepath.Rel(s.scriptDir, path)
+ if err != nil {
+ return err
+ }
+ hashTime := "(not loaded)"
+ status := ""
+ if ri, ok := s.scriptCache.Load(rel); ok {
+ r := ri.(*scriptResult)
+ r.mu.RLock()
+ defer r.mu.RUnlock()
+
+ if !r.hashTime.IsZero() {
+ hashTime = r.hashTime.Format(time.RFC3339)
+ }
+ if r.err == nil {
+ status = "ok"
+ } else {
+ status = r.err.Error()
+ }
+ }
+ fmt.Fprintf(tw, "%s\t%s\t%s\n", rel, hashTime, status)
+ return nil
+ })
+ tw.Flush()
+
+ if err != nil {
+ fmt.Fprintln(w, err)
+ }
+}
+
+// help serves a plain-text summary of the server's supported script language.
+func (s *Server) help(w http.ResponseWriter, req *http.Request) {
+ st, err := s.newState(req.Context(), s.workDir)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ scriptLog := new(strings.Builder)
+ err = s.engine.Execute(st, "help", bufio.NewReader(strings.NewReader("help")), scriptLog)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ w.Header().Set("Content-Type", "text/plain; charset=UTF-8")
+ io.WriteString(w, scriptLog.String())
+}
diff --git a/src/cmd/go/internal/vcweb/vcweb_test.go b/src/cmd/go/internal/vcweb/vcweb_test.go
new file mode 100644
index 0000000..20b2137
--- /dev/null
+++ b/src/cmd/go/internal/vcweb/vcweb_test.go
@@ -0,0 +1,63 @@
+// Copyright 2022 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 vcweb_test
+
+import (
+ "cmd/go/internal/vcweb"
+ "io"
+ "log"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "testing"
+)
+
+func TestHelp(t *testing.T) {
+ s, err := vcweb.NewServer(os.DevNull, t.TempDir(), log.Default())
+ if err != nil {
+ t.Fatal(err)
+ }
+ srv := httptest.NewServer(s)
+ defer srv.Close()
+
+ resp, err := http.Get(srv.URL + "/help")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != 200 {
+ t.Fatal(resp.Status)
+ }
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Logf("%s", body)
+}
+
+func TestOverview(t *testing.T) {
+ s, err := vcweb.NewServer(os.DevNull, t.TempDir(), log.Default())
+ if err != nil {
+ t.Fatal(err)
+ }
+ srv := httptest.NewServer(s)
+ defer srv.Close()
+
+ resp, err := http.Get(srv.URL)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != 200 {
+ t.Fatal(resp.Status)
+ }
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Logf("%s", body)
+}