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

View as plain text