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  }