diff options
Diffstat (limited to 'src/cmd/go/internal/vcweb')
-rw-r--r-- | src/cmd/go/internal/vcweb/auth.go | 108 | ||||
-rw-r--r-- | src/cmd/go/internal/vcweb/bzr.go | 18 | ||||
-rw-r--r-- | src/cmd/go/internal/vcweb/dir.go | 19 | ||||
-rw-r--r-- | src/cmd/go/internal/vcweb/fossil.go | 61 | ||||
-rw-r--r-- | src/cmd/go/internal/vcweb/git.go | 71 | ||||
-rw-r--r-- | src/cmd/go/internal/vcweb/hg.go | 123 | ||||
-rw-r--r-- | src/cmd/go/internal/vcweb/insecure.go | 42 | ||||
-rw-r--r-- | src/cmd/go/internal/vcweb/script.go | 345 | ||||
-rw-r--r-- | src/cmd/go/internal/vcweb/svn.go | 199 | ||||
-rw-r--r-- | src/cmd/go/internal/vcweb/vcstest/vcstest.go | 169 | ||||
-rw-r--r-- | src/cmd/go/internal/vcweb/vcstest/vcstest_test.go | 170 | ||||
-rw-r--r-- | src/cmd/go/internal/vcweb/vcweb.go | 425 | ||||
-rw-r--r-- | src/cmd/go/internal/vcweb/vcweb_test.go | 63 |
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, ¬Installed) || 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, ¬Found) { + http.NotFound(w, req) + } else if notInstalled := (ServerNotInstalledError{}); errors.As(err, ¬Installed) || 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) +} |