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