github.com/adevinta/lava@v0.7.2/internal/gitserver/gitserver.go (about) 1 // Copyright 2023 Adevinta 2 3 // Package gitserver provides a read-only smart HTTP Git server. 4 package gitserver 5 6 import ( 7 "bytes" 8 "context" 9 "errors" 10 "fmt" 11 "io" 12 "io/fs" 13 "log/slog" 14 "math/rand" 15 "net" 16 "net/http" 17 "os" 18 "os/exec" 19 "path" 20 "path/filepath" 21 "regexp" 22 "sync" 23 ) 24 25 // ErrGit is returned by [New] when the git command cannot be run. 26 var ErrGit = errors.New("git cannot be run") 27 28 // Server represents a Git server. 29 type Server struct { 30 basePath string 31 httpsrv *http.Server 32 33 mu sync.Mutex 34 repos map[string]string 35 paths map[string]string 36 } 37 38 // New creates a git server, but doesn't start it. 39 func New() (*Server, error) { 40 if err := checkGit(); err != nil { 41 return nil, fmt.Errorf("%w: %w", ErrGit, err) 42 } 43 44 tmpPath, err := os.MkdirTemp("", "") 45 if err != nil { 46 return nil, fmt.Errorf("make temp dir: %w", err) 47 } 48 49 srv := &Server{ 50 basePath: tmpPath, 51 repos: make(map[string]string), 52 paths: make(map[string]string), 53 httpsrv: &http.Server{Handler: newSmartServer(tmpPath)}, 54 } 55 return srv, nil 56 } 57 58 // AddRepository adds a repository to the Git server. It returns the 59 // name of the new served repository. 60 func (srv *Server) AddRepository(path string) (string, error) { 61 srv.mu.Lock() 62 defer srv.mu.Unlock() 63 64 if repoName, ok := srv.repos[path]; ok { 65 return repoName, nil 66 } 67 68 dstPath, err := os.MkdirTemp(srv.basePath, "*.git") 69 if err != nil { 70 return "", fmt.Errorf("make temp dir: %w", err) 71 } 72 73 // --mirror implies --bare. Compared to --bare, --mirror not 74 // only maps local branches of the source to local branches of 75 // the target, it maps all refs (including remote-tracking 76 // branches, notes etc.) and sets up a refspec configuration 77 // such that all these refs are overwritten by a git remote 78 // update in the target repository. 79 cmd := exec.Command("git", "clone", "--mirror", path, dstPath) 80 if err = cmd.Run(); err != nil { 81 return "", fmt.Errorf("git clone: %w", err) 82 } 83 84 // Create a branch at HEAD. So, if HEAD is detached, the Git 85 // client is able to guess the reference where HEAD is 86 // pointing to. 87 // 88 // Reference: https://github.com/go-git/go-git/blob/f92cb0d49088af996433ebb106b9fc7c2adb8875/plumbing/protocol/packp/advrefs.go#L94-L104 89 branch := fmt.Sprintf("lava-%v", rand.Int63()) 90 cmd = exec.Command("git", "branch", branch) 91 cmd.Dir = dstPath 92 if err = cmd.Run(); err != nil { 93 return "", fmt.Errorf("git branch: %w", err) 94 } 95 96 repoName := filepath.Base(dstPath) 97 srv.repos[path] = repoName 98 return repoName, nil 99 } 100 101 // AddPath adds a file path to the Git server. The path is served as a 102 // Git repository with a single commit. It returns the name of the new 103 // served repository. 104 func (srv *Server) AddPath(path string) (string, error) { 105 srv.mu.Lock() 106 defer srv.mu.Unlock() 107 108 if repoName, ok := srv.paths[path]; ok { 109 return repoName, nil 110 } 111 112 dstPath, err := os.MkdirTemp(srv.basePath, "*.git") 113 if err != nil { 114 return "", fmt.Errorf("make temp dir: %w", err) 115 } 116 117 if err := fscopy(dstPath, path); err != nil { 118 return "", fmt.Errorf("copy files: %w", err) 119 } 120 121 cmd := exec.Command("git", "init") 122 cmd.Dir = dstPath 123 if err = cmd.Run(); err != nil { 124 return "", fmt.Errorf("git init: %w", err) 125 } 126 127 cmd = exec.Command("git", "add", "-f", ".") 128 cmd.Dir = dstPath 129 if err = cmd.Run(); err != nil { 130 return "", fmt.Errorf("git add: %w", err) 131 } 132 133 cmd = exec.Command( 134 "git", 135 "-c", "user.name=lava", 136 "-c", "user.email=lava@lava.local", 137 "commit", "-m", "[auto] lava", 138 ) 139 cmd.Dir = dstPath 140 if err = cmd.Run(); err != nil { 141 return "", fmt.Errorf("git commit: %w", err) 142 } 143 144 repoName := filepath.Base(dstPath) 145 srv.paths[path] = repoName 146 return repoName, nil 147 } 148 149 // fscopy copies src to dst recursively. It ignores all .git 150 // directories. 151 func fscopy(dst, src string) error { 152 err := filepath.WalkDir(src, func(path string, d fs.DirEntry, err error) error { 153 if err != nil { 154 return err 155 } 156 157 rel, err := filepath.Rel(src, path) 158 if err != nil { 159 return fmt.Errorf("rel: %w", err) 160 } 161 162 switch typ := d.Type(); { 163 case typ.IsDir(): 164 if rel == "." { 165 // The source path is a directory. The 166 // destination directory already 167 // exists, so it is not necessary to 168 // create it. 169 return nil 170 } 171 if filepath.Base(rel) == ".git" { 172 // Ignore .git directory. 173 return filepath.SkipDir 174 } 175 if err := os.MkdirAll(filepath.Join(dst, rel), 0755); err != nil { 176 return fmt.Errorf("make dir: %w", err) 177 } 178 case typ.IsRegular(): 179 if rel == "." { 180 // The source path is a file. The 181 // destination file is the name of the 182 // source file. 183 rel = filepath.Base(path) 184 } 185 fsrc, err := os.Open(path) 186 if err != nil { 187 return fmt.Errorf("open source file: %w", err) 188 } 189 defer fsrc.Close() 190 fdst, err := os.Create(filepath.Join(dst, rel)) 191 if err != nil { 192 return fmt.Errorf("create destination file: %w", err) 193 } 194 defer fdst.Close() 195 if _, err := io.Copy(fdst, fsrc); err != nil { 196 return fmt.Errorf("copy file: %w", err) 197 } 198 default: 199 slog.Warn("invalid file type", "path", path, "mode", typ) 200 } 201 return nil 202 }) 203 204 if err != nil { 205 return fmt.Errorf("walk dir: %w", err) 206 } 207 return nil 208 } 209 210 // ListenAndServe listens on the TCP network address addr and then 211 // calls [*GitServer.Serve] to handle requests on incoming 212 // connections. 213 // 214 // ListenAndServe always returns a non-nil error. 215 func (srv *Server) ListenAndServe(addr string) error { 216 l, err := net.Listen("tcp", addr) 217 if err != nil { 218 return fmt.Errorf("listen: %w", err) 219 } 220 return srv.Serve(l) 221 } 222 223 // testHookServerServe is executed at the beginning of 224 // [*GitServer.Serve] if not nil. It is set by tests. 225 var testHookServerServe func(*Server, net.Listener) 226 227 // Serve accepts incoming connections on the [http.Listener] l. 228 // 229 // Serve always returns a non-nil error and closes l. 230 func (srv *Server) Serve(l net.Listener) error { 231 if fn := testHookServerServe; fn != nil { 232 fn(srv, l) 233 } 234 return srv.httpsrv.Serve(l) 235 } 236 237 // Close stops the server and deletes any temporary directory created 238 // to store the repositories. 239 func (srv *Server) Close() error { 240 if err := srv.httpsrv.Shutdown(context.Background()); err != nil { 241 return fmt.Errorf("server shutdown: %w", err) 242 } 243 if err := os.RemoveAll(srv.basePath); err != nil { 244 return fmt.Errorf("remove temp dirs: %w", err) 245 } 246 return nil 247 } 248 249 // checkGit checks that the git command can be run. 250 func checkGit() error { 251 return exec.Command("git", "version").Run() 252 } 253 254 // smartServer provides a read-only smart HTTP Git protocol 255 // implementation. 256 type smartServer struct { 257 basePath string 258 } 259 260 // newSmartServer returns a new [smartServer]. Served repositories are 261 // relative to basePath. 262 func newSmartServer(basePath string) *smartServer { 263 return &smartServer{basePath: basePath} 264 } 265 266 // pathRE is used to parse HTTP requests. 267 var pathRE = regexp.MustCompile(`^(/.*?)(/.*)$`) 268 269 // ServeHTTP implements the smart server router. 270 func (srv *smartServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { 271 matches := pathRE.FindStringSubmatch(path.Clean(r.URL.Path)) 272 if matches == nil { 273 w.WriteHeader(http.StatusNotFound) 274 return 275 } 276 repo := matches[1] 277 endpoint := matches[2] 278 279 switch endpoint { 280 case "/info/refs": 281 srv.handleInfoRefs(w, r, repo) 282 case "/git-upload-pack": 283 srv.handleGitUploadPack(w, r, repo) 284 default: 285 w.WriteHeader(http.StatusNotFound) 286 } 287 } 288 289 // handleInfoRefs handles requests to /repo/info/refs. 290 func (srv *smartServer) handleInfoRefs(w http.ResponseWriter, r *http.Request, repo string) { 291 if r.Method != "GET" { 292 w.WriteHeader(http.StatusNotFound) 293 return 294 } 295 296 if r.URL.Query().Get("service") != "git-upload-pack" { 297 w.WriteHeader(http.StatusForbidden) 298 return 299 } 300 301 buf := &bytes.Buffer{} 302 repoPath := filepath.Join(srv.basePath, repo) 303 cmd := exec.Command("git-upload-pack", "--advertise-refs", repoPath) 304 cmd.Stdout = buf 305 if err := cmd.Run(); err != nil { 306 w.WriteHeader(http.StatusInternalServerError) 307 return 308 } 309 310 w.Header().Set("Content-Type", "application/x-git-upload-pack-advertisement") 311 312 pkt := "# service=git-upload-pack\n" 313 fmt.Fprintf(w, "%04x%v0000%v", len(pkt)+4, pkt, buf) 314 } 315 316 // handleGitUploadPack handles requests to /repo/git-upload-pack. 317 func (srv *smartServer) handleGitUploadPack(w http.ResponseWriter, r *http.Request, repo string) { 318 if r.Method != "POST" { 319 w.WriteHeader(http.StatusNotFound) 320 return 321 } 322 323 buf := &bytes.Buffer{} 324 repoPath := filepath.Join(srv.basePath, repo) 325 cmd := exec.Command("git-upload-pack", "--stateless-rpc", repoPath) 326 cmd.Stdin = r.Body 327 cmd.Stdout = buf 328 if err := cmd.Run(); err != nil { 329 w.WriteHeader(http.StatusInternalServerError) 330 return 331 } 332 333 w.Header().Set("Content-Type", "application/x-git-upload-pack-result") 334 fmt.Fprintf(w, "%v", buf) 335 }