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 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
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
68
69
70
71
72
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
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
127
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
137 for _, k := range []string{
138 pathEnvName(),
139 tempEnvName(),
140 "SYSTEMROOT",
141 "WINDIR",
142 "ComSpec",
143 "DYLD_LIBRARY_PATH",
144 "LD_LIBRARY_PATH",
145 "LIBRARY_PATH",
146 "PYTHONPATH",
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
155
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
166
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
179
180 func tempEnvName() string {
181 switch runtime.GOOS {
182 case "windows":
183 return "TMP"
184 case "plan9":
185 return "TMPDIR"
186 default:
187 return "TMPDIR"
188 }
189 }
190
191
192
193 func pathEnvName() string {
194 switch runtime.GOOS {
195 case "plan9":
196 return "path"
197 default:
198 return "PATH"
199 }
200 }
201
202
203
204 type scriptCtx struct {
205 context.Context
206 server *Server
207 commitTime time.Time
208 handlerName string
209 handler http.Handler
210 }
211
212
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
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