1
2
3
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
33
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
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
72
73
74
75
76
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
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
131
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
141 for _, k := range []string{
142 pathEnvName(),
143 tempEnvName(),
144 "SYSTEMROOT",
145 "WINDIR",
146 "ComSpec",
147 "DYLD_LIBRARY_PATH",
148 "LD_LIBRARY_PATH",
149 "LIBRARY_PATH",
150 "PYTHONPATH",
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
159
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
170
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
183
184 func tempEnvName() string {
185 switch runtime.GOOS {
186 case "windows":
187 return "TMP"
188 case "plan9":
189 return "TMPDIR"
190 default:
191 return "TMPDIR"
192 }
193 }
194
195
196
197 func pathEnvName() string {
198 switch runtime.GOOS {
199 case "plan9":
200 return "path"
201 default:
202 return "PATH"
203 }
204 }
205
206
207
208 type scriptCtx struct {
209 context.Context
210 server *Server
211 commitTime time.Time
212 handlerName string
213 handler http.Handler
214 }
215
216
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
396
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