1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30 package vcweb
31
32 import (
33 "bufio"
34 "cmd/internal/script"
35 "context"
36 "crypto/sha256"
37 "errors"
38 "fmt"
39 "io"
40 "io/fs"
41 "log"
42 "net/http"
43 "os"
44 "os/exec"
45 "path"
46 "path/filepath"
47 "runtime/debug"
48 "strings"
49 "sync"
50 "text/tabwriter"
51 "time"
52 )
53
54
55 type Server struct {
56 env []string
57 logger *log.Logger
58
59 scriptDir string
60 workDir string
61 homeDir string
62 engine *script.Engine
63
64 scriptCache sync.Map
65
66 vcsHandlers map[string]vcsHandler
67 }
68
69
70 type vcsHandler interface {
71 Available() bool
72 Handler(dir string, env []string, logger *log.Logger) (http.Handler, error)
73 }
74
75
76 type scriptResult struct {
77 mu sync.RWMutex
78
79 hash [sha256.Size]byte
80 hashTime time.Time
81
82 handler http.Handler
83 err error
84 }
85
86
87
88
89
90
91
92 func NewServer(scriptDir, workDir string, logger *log.Logger) (*Server, error) {
93 if scriptDir == "" {
94 panic("vcweb.NewServer: scriptDir is required")
95 }
96 var err error
97 scriptDir, err = filepath.Abs(scriptDir)
98 if err != nil {
99 return nil, err
100 }
101
102 if workDir == "" {
103 workDir, err = os.MkdirTemp("", "vcweb-*")
104 if err != nil {
105 return nil, err
106 }
107 logger.Printf("vcweb work directory: %s", workDir)
108 } else {
109 workDir, err = filepath.Abs(workDir)
110 if err != nil {
111 return nil, err
112 }
113 }
114
115 homeDir := filepath.Join(workDir, "home")
116 if err := os.MkdirAll(homeDir, 0755); err != nil {
117 return nil, err
118 }
119
120 env := scriptEnviron(homeDir)
121
122 s := &Server{
123 env: env,
124 logger: logger,
125 scriptDir: scriptDir,
126 workDir: workDir,
127 homeDir: homeDir,
128 engine: newScriptEngine(),
129 vcsHandlers: map[string]vcsHandler{
130 "auth": new(authHandler),
131 "dir": new(dirHandler),
132 "fossil": new(fossilHandler),
133 "git": new(gitHandler),
134 "hg": new(hgHandler),
135 "insecure": new(insecureHandler),
136 "svn": &svnHandler{svnRoot: workDir, logger: logger},
137 },
138 }
139
140 if err := os.WriteFile(filepath.Join(s.homeDir, ".gitconfig"), []byte(gitConfig), 0644); err != nil {
141 return nil, err
142 }
143 gitConfigDir := filepath.Join(s.homeDir, ".config", "git")
144 if err := os.MkdirAll(gitConfigDir, 0755); err != nil {
145 return nil, err
146 }
147 if err := os.WriteFile(filepath.Join(gitConfigDir, "ignore"), []byte(""), 0644); err != nil {
148 return nil, err
149 }
150
151 if err := os.WriteFile(filepath.Join(s.homeDir, ".hgrc"), []byte(hgrc), 0644); err != nil {
152 return nil, err
153 }
154
155 return s, nil
156 }
157
158 func (s *Server) Close() error {
159 var firstErr error
160 for _, h := range s.vcsHandlers {
161 if c, ok := h.(io.Closer); ok {
162 if closeErr := c.Close(); firstErr == nil {
163 firstErr = closeErr
164 }
165 }
166 }
167 return firstErr
168 }
169
170
171
172 var gitConfig = `
173 [user]
174 name = Go Gopher
175 email = gopher@golang.org
176 [init]
177 defaultBranch = main
178 [core]
179 eol = lf
180 [gui]
181 encoding = utf-8
182 `[1:]
183
184
185
186 var hgrc = `
187 [ui]
188 username=Go Gopher <gopher@golang.org>
189 [phases]
190 new-commit=public
191 [extensions]
192 convert=
193 `[1:]
194
195
196 func (s *Server) ServeHTTP(w http.ResponseWriter, req *http.Request) {
197 s.logger.Printf("serving %s", req.URL)
198
199 defer func() {
200 if v := recover(); v != nil {
201 if v == http.ErrAbortHandler {
202 panic(v)
203 }
204 s.logger.Fatalf("panic serving %s: %v\n%s", req.URL, v, debug.Stack())
205 }
206 }()
207
208 urlPath := req.URL.Path
209 if !strings.HasPrefix(urlPath, "/") {
210 urlPath = "/" + urlPath
211 }
212 clean := path.Clean(urlPath)[1:]
213 if clean == "" {
214 s.overview(w, req)
215 return
216 }
217 if clean == "help" {
218 s.help(w, req)
219 return
220 }
221
222
223
224
225
226
227 scriptPath := "."
228 for part := range strings.SplitSeq(clean, "/") {
229 scriptPath = filepath.Join(scriptPath, part)
230 dir := filepath.Join(s.scriptDir, scriptPath)
231 if _, err := os.Stat(dir); err != nil {
232 if !os.IsNotExist(err) {
233 http.Error(w, err.Error(), http.StatusInternalServerError)
234 return
235 }
236
237
238 break
239 }
240 }
241 scriptPath += ".txt"
242
243 err := s.HandleScript(scriptPath, s.logger, func(handler http.Handler) {
244 handler.ServeHTTP(w, req)
245 })
246 if err != nil {
247 s.logger.Print(err)
248 if _, ok := errors.AsType[ScriptNotFoundError](err); ok {
249 http.NotFound(w, req)
250 } else if _, ok := errors.AsType[ServerNotInstalledError](err); ok || errors.Is(err, exec.ErrNotFound) {
251 http.Error(w, err.Error(), http.StatusNotImplemented)
252 } else {
253 http.Error(w, err.Error(), http.StatusInternalServerError)
254 }
255 }
256 }
257
258
259
260 type ScriptNotFoundError struct{ err error }
261
262 func (e ScriptNotFoundError) Error() string { return e.err.Error() }
263 func (e ScriptNotFoundError) Unwrap() error { return e.err }
264
265
266
267 type ServerNotInstalledError struct{ name string }
268
269 func (v ServerNotInstalledError) Error() string {
270 return fmt.Sprintf("server for %#q VCS is not installed", v.name)
271 }
272
273
274
275
276
277
278
279
280
281 func (s *Server) HandleScript(scriptRelPath string, logger *log.Logger, f func(http.Handler)) error {
282 ri, ok := s.scriptCache.Load(scriptRelPath)
283 if !ok {
284 ri, _ = s.scriptCache.LoadOrStore(scriptRelPath, new(scriptResult))
285 }
286 r := ri.(*scriptResult)
287
288 relDir := strings.TrimSuffix(scriptRelPath, filepath.Ext(scriptRelPath))
289 workDir := filepath.Join(s.workDir, relDir)
290 prefix := path.Join("/", filepath.ToSlash(relDir))
291
292 r.mu.RLock()
293 defer r.mu.RUnlock()
294 for {
295
296
297
298
299
300
301 content, err := os.ReadFile(filepath.Join(s.scriptDir, scriptRelPath))
302 if err != nil {
303 if !os.IsNotExist(err) {
304 return err
305 }
306 return ScriptNotFoundError{err}
307 }
308
309 hash := sha256.Sum256(content)
310 if prevHash := r.hash; prevHash != hash {
311
312 func() {
313 r.mu.RUnlock()
314 r.mu.Lock()
315 defer func() {
316 r.mu.Unlock()
317 r.mu.RLock()
318 }()
319 if r.hash != prevHash {
320
321
322
323 return
324 }
325
326 r.hash = hash
327 r.hashTime = time.Now()
328 r.handler, r.err = nil, nil
329
330 if err := os.RemoveAll(workDir); err != nil {
331 r.err = err
332 return
333 }
334
335
336
337
338 scriptHandler, err := s.loadScript(context.Background(), logger, scriptRelPath, content, workDir)
339 if err != nil {
340 r.err = err
341 return
342 }
343 r.handler = http.StripPrefix(prefix, scriptHandler)
344 }()
345 }
346
347 if r.hash != hash {
348 continue
349 }
350
351 if r.err != nil {
352 return r.err
353 }
354 f(r.handler)
355 return nil
356 }
357 }
358
359
360
361 func (s *Server) overview(w http.ResponseWriter, r *http.Request) {
362 fmt.Fprintf(w, "<html>\n")
363 fmt.Fprintf(w, "<title>vcweb</title>\n<pre>\n")
364 fmt.Fprintf(w, "<b>vcweb</b>\n\n")
365 fmt.Fprintf(w, "This server serves various version control repos for testing the go command.\n\n")
366 fmt.Fprintf(w, "For an overview of the script language, see <a href=\"/help\">/help</a>.\n\n")
367
368 fmt.Fprintf(w, "<b>cache</b>\n")
369
370 tw := tabwriter.NewWriter(w, 1, 8, 1, '\t', 0)
371 err := filepath.WalkDir(s.scriptDir, func(path string, d fs.DirEntry, err error) error {
372 if err != nil {
373 return err
374 }
375 if filepath.Ext(path) != ".txt" {
376 return nil
377 }
378
379 rel, err := filepath.Rel(s.scriptDir, path)
380 if err != nil {
381 return err
382 }
383 hashTime := "(not loaded)"
384 status := ""
385 if ri, ok := s.scriptCache.Load(rel); ok {
386 r := ri.(*scriptResult)
387 r.mu.RLock()
388 defer r.mu.RUnlock()
389
390 if !r.hashTime.IsZero() {
391 hashTime = r.hashTime.Format(time.RFC3339)
392 }
393 if r.err == nil {
394 status = "ok"
395 } else {
396 status = r.err.Error()
397 }
398 }
399 fmt.Fprintf(tw, "%s\t%s\t%s\n", rel, hashTime, status)
400 return nil
401 })
402 tw.Flush()
403
404 if err != nil {
405 fmt.Fprintln(w, err)
406 }
407 }
408
409
410 func (s *Server) help(w http.ResponseWriter, req *http.Request) {
411 st, err := s.newState(req.Context(), s.workDir)
412 if err != nil {
413 http.Error(w, err.Error(), http.StatusInternalServerError)
414 return
415 }
416
417 scriptLog := new(strings.Builder)
418 err = s.engine.Execute(st, "help", bufio.NewReader(strings.NewReader("help")), scriptLog)
419 if err != nil {
420 http.Error(w, err.Error(), http.StatusInternalServerError)
421 return
422 }
423
424 w.Header().Set("Content-Type", "text/plain; charset=UTF-8")
425 io.WriteString(w, scriptLog.String())
426 }
427
View as plain text