Source file src/cmd/go/internal/vcweb/script.go

     1  // Copyright 2022 The Go Authors. All rights reserved.
     2  // Use of this source code is governed by a BSD-style
     3  // license that can be found in the LICENSE file.
     4  
     5  package vcweb
     6  
     7  import (
     8  	"bufio"
     9  	"bytes"
    10  	"cmd/internal/script"
    11  	"context"
    12  	"errors"
    13  	"fmt"
    14  	"internal/txtar"
    15  	"io"
    16  	"log"
    17  	"net/http"
    18  	"os"
    19  	"os/exec"
    20  	"path/filepath"
    21  	"regexp"
    22  	"runtime"
    23  	"strconv"
    24  	"strings"
    25  	"time"
    26  
    27  	"golang.org/x/mod/module"
    28  	"golang.org/x/mod/semver"
    29  	"golang.org/x/mod/zip"
    30  )
    31  
    32  // newScriptEngine returns a script engine augmented with commands for
    33  // reproducing version-control repositories by replaying commits.
    34  func newScriptEngine() *script.Engine {
    35  	conds := script.DefaultConds()
    36  
    37  	add := func(name string, cond script.Cond) {
    38  		if _, ok := conds[name]; ok {
    39  			panic(fmt.Sprintf("condition %q is already registered", name))
    40  		}
    41  		conds[name] = cond
    42  	}
    43  	add("git-sha256", script.OnceCondition("the local 'git' version is recent enough to support sha256 object/commit hashes", gitSupportsSHA256))
    44  
    45  	interrupt := func(cmd *exec.Cmd) error { return cmd.Process.Signal(os.Interrupt) }
    46  	gracePeriod := 30 * time.Second // arbitrary
    47  
    48  	cmds := script.DefaultCmds()
    49  	cmds["at"] = scriptAt()
    50  	cmds["fossil"] = script.Program("fossil", interrupt, gracePeriod)
    51  	cmds["git"] = script.Program("git", interrupt, gracePeriod)
    52  	cmds["hg"] = script.Program("hg", interrupt, gracePeriod)
    53  	cmds["handle"] = scriptHandle()
    54  	cmds["modzip"] = scriptModzip()
    55  	cmds["skip"] = scriptSkip()
    56  	cmds["status"] = scriptStatus()
    57  	cmds["svnadmin"] = script.Program("svnadmin", interrupt, gracePeriod)
    58  	cmds["svn"] = script.Program("svn", interrupt, gracePeriod)
    59  	cmds["unquote"] = scriptUnquote()
    60  
    61  	return &script.Engine{
    62  		Cmds:  cmds,
    63  		Conds: conds,
    64  	}
    65  }
    66  
    67  // loadScript interprets the given script content using the vcweb script engine.
    68  // loadScript always returns either a non-nil handler or a non-nil error.
    69  //
    70  // The script content must be a txtar archive with a comment containing a script
    71  // with exactly one "handle" command and zero or more VCS commands to prepare
    72  // the repository to be served.
    73  func (s *Server) loadScript(ctx context.Context, logger *log.Logger, scriptPath string, scriptContent []byte, workDir string) (http.Handler, error) {
    74  	ar := txtar.Parse(scriptContent)
    75  
    76  	if err := os.MkdirAll(workDir, 0755); err != nil {
    77  		return nil, err
    78  	}
    79  
    80  	st, err := s.newState(ctx, workDir)
    81  	if err != nil {
    82  		return nil, err
    83  	}
    84  	if err := st.ExtractFiles(ar); err != nil {
    85  		return nil, err
    86  	}
    87  
    88  	scriptName := filepath.Base(scriptPath)
    89  	scriptLog := new(strings.Builder)
    90  	err = s.engine.Execute(st, scriptName, bufio.NewReader(bytes.NewReader(ar.Comment)), scriptLog)
    91  	closeErr := st.CloseAndWait(scriptLog)
    92  	logger.Printf("%s:", scriptName)
    93  	io.WriteString(logger.Writer(), scriptLog.String())
    94  	io.WriteString(logger.Writer(), "\n")
    95  	if err != nil {
    96  		return nil, err
    97  	}
    98  	if closeErr != nil {
    99  		return nil, err
   100  	}
   101  
   102  	sc, err := getScriptCtx(st)
   103  	if err != nil {
   104  		return nil, err
   105  	}
   106  	if sc.handler == nil {
   107  		return nil, errors.New("script completed without setting handler")
   108  	}
   109  	return sc.handler, nil
   110  }
   111  
   112  // newState returns a new script.State for executing scripts in workDir.
   113  func (s *Server) newState(ctx context.Context, workDir string) (*script.State, error) {
   114  	ctx = &scriptCtx{
   115  		Context: ctx,
   116  		server:  s,
   117  	}
   118  
   119  	st, err := script.NewState(ctx, workDir, s.env)
   120  	if err != nil {
   121  		return nil, err
   122  	}
   123  	return st, nil
   124  }
   125  
   126  // scriptEnviron returns a new environment that attempts to provide predictable
   127  // behavior for the supported version-control tools.
   128  func scriptEnviron(homeDir string) []string {
   129  	env := []string{
   130  		"USER=gopher",
   131  		homeEnvName() + "=" + homeDir,
   132  		"GIT_CONFIG_NOSYSTEM=1",
   133  		"HGRCPATH=" + filepath.Join(homeDir, ".hgrc"),
   134  		"HGENCODING=utf-8",
   135  	}
   136  	// Preserve additional environment variables that may be needed by VCS tools.
   137  	for _, k := range []string{
   138  		pathEnvName(),
   139  		tempEnvName(),
   140  		"SYSTEMROOT",        // must be preserved on Windows to find DLLs; golang.org/issue/25210
   141  		"WINDIR",            // must be preserved on Windows to be able to run PowerShell command; golang.org/issue/30711
   142  		"ComSpec",           // must be preserved on Windows to be able to run Batch files; golang.org/issue/56555
   143  		"DYLD_LIBRARY_PATH", // must be preserved on macOS systems to find shared libraries
   144  		"LD_LIBRARY_PATH",   // must be preserved on Unix systems to find shared libraries
   145  		"LIBRARY_PATH",      // allow override of non-standard static library paths
   146  		"PYTHONPATH",        // may be needed by hg to find imported modules
   147  	} {
   148  		if v, ok := os.LookupEnv(k); ok {
   149  			env = append(env, k+"="+v)
   150  		}
   151  	}
   152  
   153  	if os.Getenv("GO_BUILDER_NAME") != "" || os.Getenv("GIT_TRACE_CURL") == "1" {
   154  		// To help diagnose https://go.dev/issue/52545,
   155  		// enable tracing for Git HTTPS requests.
   156  		env = append(env,
   157  			"GIT_TRACE_CURL=1",
   158  			"GIT_TRACE_CURL_NO_DATA=1",
   159  			"GIT_REDACT_COOKIES=o,SSO,GSSO_Uberproxy")
   160  	}
   161  
   162  	return env
   163  }
   164  
   165  // homeEnvName returns the environment variable used by os.UserHomeDir
   166  // to locate the user's home directory.
   167  func homeEnvName() string {
   168  	switch runtime.GOOS {
   169  	case "windows":
   170  		return "USERPROFILE"
   171  	case "plan9":
   172  		return "home"
   173  	default:
   174  		return "HOME"
   175  	}
   176  }
   177  
   178  // tempEnvName returns the environment variable used by os.TempDir
   179  // to locate the default directory for temporary files.
   180  func tempEnvName() string {
   181  	switch runtime.GOOS {
   182  	case "windows":
   183  		return "TMP"
   184  	case "plan9":
   185  		return "TMPDIR" // actually plan 9 doesn't have one at all but this is fine
   186  	default:
   187  		return "TMPDIR"
   188  	}
   189  }
   190  
   191  // pathEnvName returns the environment variable used by exec.LookPath to
   192  // identify directories to search for executables.
   193  func pathEnvName() string {
   194  	switch runtime.GOOS {
   195  	case "plan9":
   196  		return "path"
   197  	default:
   198  		return "PATH"
   199  	}
   200  }
   201  
   202  // A scriptCtx is a context.Context that stores additional state for script
   203  // commands.
   204  type scriptCtx struct {
   205  	context.Context
   206  	server      *Server
   207  	commitTime  time.Time
   208  	handlerName string
   209  	handler     http.Handler
   210  }
   211  
   212  // scriptCtxKey is the key associating the *scriptCtx in a script's Context..
   213  type scriptCtxKey struct{}
   214  
   215  func (sc *scriptCtx) Value(key any) any {
   216  	if key == (scriptCtxKey{}) {
   217  		return sc
   218  	}
   219  	return sc.Context.Value(key)
   220  }
   221  
   222  func getScriptCtx(st *script.State) (*scriptCtx, error) {
   223  	sc, ok := st.Context().Value(scriptCtxKey{}).(*scriptCtx)
   224  	if !ok {
   225  		return nil, errors.New("scriptCtx not found in State.Context")
   226  	}
   227  	return sc, nil
   228  }
   229  
   230  func scriptAt() script.Cmd {
   231  	return script.Command(
   232  		script.CmdUsage{
   233  			Summary: "set the current commit time for all version control systems",
   234  			Args:    "time",
   235  			Detail: []string{
   236  				"The argument must be an absolute timestamp in RFC3339 format.",
   237  			},
   238  		},
   239  		func(st *script.State, args ...string) (script.WaitFunc, error) {
   240  			if len(args) != 1 {
   241  				return nil, script.ErrUsage
   242  			}
   243  
   244  			sc, err := getScriptCtx(st)
   245  			if err != nil {
   246  				return nil, err
   247  			}
   248  
   249  			sc.commitTime, err = time.ParseInLocation(time.RFC3339, args[0], time.UTC)
   250  			if err == nil {
   251  				st.Setenv("GIT_COMMITTER_DATE", args[0])
   252  				st.Setenv("GIT_AUTHOR_DATE", args[0])
   253  			}
   254  			return nil, err
   255  		})
   256  }
   257  
   258  func scriptHandle() script.Cmd {
   259  	return script.Command(
   260  		script.CmdUsage{
   261  			Summary: "set the HTTP handler that will serve the script's output",
   262  			Args:    "handler [dir]",
   263  			Detail: []string{
   264  				"The handler will be passed the script's current working directory and environment as arguments.",
   265  				"Valid handlers include 'dir' (for general http.Dir serving), 'fossil', 'git', and 'hg'",
   266  			},
   267  		},
   268  		func(st *script.State, args ...string) (script.WaitFunc, error) {
   269  			if len(args) == 0 || len(args) > 2 {
   270  				return nil, script.ErrUsage
   271  			}
   272  
   273  			sc, err := getScriptCtx(st)
   274  			if err != nil {
   275  				return nil, err
   276  			}
   277  
   278  			if sc.handler != nil {
   279  				return nil, fmt.Errorf("server handler already set to %s", sc.handlerName)
   280  			}
   281  
   282  			name := args[0]
   283  			h, ok := sc.server.vcsHandlers[name]
   284  			if !ok {
   285  				return nil, fmt.Errorf("unrecognized VCS %q", name)
   286  			}
   287  			sc.handlerName = name
   288  			if !h.Available() {
   289  				return nil, ServerNotInstalledError{name}
   290  			}
   291  
   292  			dir := st.Getwd()
   293  			if len(args) >= 2 {
   294  				dir = st.Path(args[1])
   295  			}
   296  			sc.handler, err = h.Handler(dir, st.Environ(), sc.server.logger)
   297  			return nil, err
   298  		})
   299  }
   300  
   301  func scriptModzip() script.Cmd {
   302  	return script.Command(
   303  		script.CmdUsage{
   304  			Summary: "create a Go module zip file from a directory",
   305  			Args:    "zipfile path@version dir",
   306  		},
   307  		func(st *script.State, args ...string) (wait script.WaitFunc, err error) {
   308  			if len(args) != 3 {
   309  				return nil, script.ErrUsage
   310  			}
   311  			zipPath := st.Path(args[0])
   312  			mPath, version, ok := strings.Cut(args[1], "@")
   313  			if !ok {
   314  				return nil, script.ErrUsage
   315  			}
   316  			dir := st.Path(args[2])
   317  
   318  			if err := os.MkdirAll(filepath.Dir(zipPath), 0755); err != nil {
   319  				return nil, err
   320  			}
   321  			f, err := os.Create(zipPath)
   322  			if err != nil {
   323  				return nil, err
   324  			}
   325  			defer func() {
   326  				if closeErr := f.Close(); err == nil {
   327  					err = closeErr
   328  				}
   329  			}()
   330  
   331  			return nil, zip.CreateFromDir(f, module.Version{Path: mPath, Version: version}, dir)
   332  		})
   333  }
   334  
   335  func scriptSkip() script.Cmd {
   336  	return script.Command(
   337  		script.CmdUsage{
   338  			Summary: "skip the current test",
   339  			Args:    "[msg]",
   340  		},
   341  		func(_ *script.State, args ...string) (script.WaitFunc, error) {
   342  			if len(args) > 1 {
   343  				return nil, script.ErrUsage
   344  			}
   345  			if len(args) == 0 {
   346  				return nil, SkipError{""}
   347  			}
   348  			return nil, SkipError{args[0]}
   349  		})
   350  }
   351  
   352  type statusWriter struct {
   353  	http.ResponseWriter
   354  	status int
   355  }
   356  
   357  func (w *statusWriter) WriteHeader(code int) {
   358  	w.ResponseWriter.WriteHeader(w.status)
   359  }
   360  
   361  type statusCodeHandler struct {
   362  	handler    http.Handler
   363  	statusCode int
   364  }
   365  
   366  func (h *statusCodeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
   367  	h.handler.ServeHTTP(&statusWriter{ResponseWriter: w, status: h.statusCode}, r)
   368  }
   369  
   370  func scriptStatus() script.Cmd {
   371  	return script.Command(
   372  		script.CmdUsage{
   373  			Summary: "set the HTTP status code for the handler",
   374  			Args:    "code",
   375  		},
   376  		func(st *script.State, args ...string) (script.WaitFunc, error) {
   377  			if len(args) != 1 {
   378  				return nil, script.ErrUsage
   379  			}
   380  			sc, err := getScriptCtx(st)
   381  			if err != nil {
   382  				return nil, err
   383  			}
   384  			if sc.handler == nil {
   385  				return nil, errors.New("status command must be called after handle")
   386  			}
   387  			code, err := strconv.Atoi(args[0])
   388  			if err != nil {
   389  				return nil, err
   390  			}
   391  			sc.handler = &statusCodeHandler{handler: sc.handler, statusCode: code}
   392  			return nil, nil
   393  		})
   394  }
   395  
   396  type SkipError struct {
   397  	Msg string
   398  }
   399  
   400  func (s SkipError) Error() string {
   401  	if s.Msg == "" {
   402  		return "skip"
   403  	}
   404  	return s.Msg
   405  }
   406  
   407  func scriptUnquote() script.Cmd {
   408  	return script.Command(
   409  		script.CmdUsage{
   410  			Summary: "unquote the argument as a Go string",
   411  			Args:    "string",
   412  		},
   413  		func(st *script.State, args ...string) (script.WaitFunc, error) {
   414  			if len(args) != 1 {
   415  				return nil, script.ErrUsage
   416  			}
   417  
   418  			s, err := strconv.Unquote(`"` + args[0] + `"`)
   419  			if err != nil {
   420  				return nil, err
   421  			}
   422  
   423  			wait := func(*script.State) (stdout, stderr string, err error) {
   424  				return s, "", nil
   425  			}
   426  			return wait, nil
   427  		})
   428  }
   429  
   430  // Capture the major, minor and (optionally) patch version, but ignore anything later
   431  var gitVersLineExtract = regexp.MustCompile(`git version\s+(\d+\.\d+(?:\.\d+)?)`)
   432  
   433  func gitVersion() (string, error) {
   434  	gitOut, runErr := exec.Command("git", "version").CombinedOutput()
   435  	if runErr != nil {
   436  		return "v0", fmt.Errorf("failed to execute git version: %w", runErr)
   437  	}
   438  	matches := gitVersLineExtract.FindSubmatch(gitOut)
   439  	if len(matches) < 2 {
   440  		return "v0", fmt.Errorf("git version extraction regexp did not match version line: %q", gitOut)
   441  	}
   442  	return "v" + string(matches[1]), nil
   443  }
   444  
   445  func hasAtLeastGitVersion(minVers string) (bool, error) {
   446  	gitVers, gitVersErr := gitVersion()
   447  	if gitVersErr != nil {
   448  		return false, gitVersErr
   449  	}
   450  	return semver.Compare(minVers, gitVers) <= 0, nil
   451  }
   452  
   453  func gitSupportsSHA256() (bool, error) {
   454  	return hasAtLeastGitVersion("v2.29")
   455  }
   456  

View as plain text