devtools/depotview: init

This is a small service for accessing git repos read-only over gRPC.

It's going to be used to allow hackdoc to render arbitrary versions of
hscloud.

Change-Id: Ib3c5eb5a8bc679e8062142e6fa30505d9550e2fa
diff --git a/devtools/depotview/service/service.go b/devtools/depotview/service/service.go
new file mode 100644
index 0000000..910bf36
--- /dev/null
+++ b/devtools/depotview/service/service.go
@@ -0,0 +1,221 @@
+package service
+
+import (
+	"context"
+	"io"
+	"regexp"
+	"strings"
+	"sync"
+	"time"
+
+	"github.com/golang/glog"
+	"google.golang.org/grpc/codes"
+	"google.golang.org/grpc/status"
+
+	git "github.com/go-git/go-git/v5"
+	"github.com/go-git/go-git/v5/plumbing"
+	"github.com/go-git/go-git/v5/plumbing/filemode"
+	"github.com/go-git/go-git/v5/plumbing/object"
+	"github.com/go-git/go-git/v5/storage"
+	"github.com/go-git/go-git/v5/storage/memory"
+
+	pb "code.hackerspace.pl/hscloud/devtools/depotview/proto"
+)
+
+var (
+	reHash = regexp.MustCompile(`[a-f0-9]{40,64}`)
+)
+
+type Service struct {
+	remote string
+	storer storage.Storer
+
+	mu       sync.Mutex
+	repo     *git.Repository
+	lastPull time.Time
+}
+
+func New(remote string) *Service {
+	return &Service{
+		remote: remote,
+		storer: memory.NewStorage(),
+	}
+}
+
+func (s *Service) ensureRepo() error {
+	// Clone repository if necessary.
+	if s.repo == nil {
+		repo, err := git.Clone(s.storer, nil, &git.CloneOptions{
+			URL: s.remote,
+		})
+		if err != nil {
+			glog.Errorf("Clone(%q): %v", s.remote, err)
+			return status.Error(codes.Unavailable, "could not clone repository")
+		}
+		s.repo = repo
+		s.lastPull = time.Now()
+	}
+
+	// Fetch if necessary.
+	if time.Since(s.lastPull) > time.Minute {
+		err := s.repo.Fetch(&git.FetchOptions{})
+		if err != nil && err != git.NoErrAlreadyUpToDate {
+			glog.Errorf("Fetch(): %v", err)
+		} else {
+			s.lastPull = time.Now()
+		}
+	}
+
+	return nil
+}
+
+func (s *Service) Resolve(ctx context.Context, req *pb.ResolveRequest) (*pb.ResolveResponse, error) {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+
+	if req.Ref == "" {
+		return nil, status.Error(codes.InvalidArgument, "ref must be set")
+	}
+
+	if err := s.ensureRepo(); err != nil {
+		return nil, err
+	}
+
+	h, err := s.repo.ResolveRevision(plumbing.Revision(req.Ref))
+	switch {
+	case err == plumbing.ErrReferenceNotFound:
+		return &pb.ResolveResponse{Hash: "", LastChecked: s.lastPull.UnixNano()}, nil
+	case err != nil:
+		return nil, status.Errorf(codes.Unavailable, "git resolve error: %v", err)
+	default:
+		return &pb.ResolveResponse{Hash: h.String(), LastChecked: s.lastPull.UnixNano()}, nil
+	}
+}
+
+func (s *Service) getFile(hash, path string, notFoundOkay bool) (*object.File, error) {
+	if !reHash.MatchString(hash) {
+		return nil, status.Error(codes.InvalidArgument, "hash must be valid full git hash string")
+	}
+	if path == "" {
+		return nil, status.Error(codes.InvalidArgument, "path must be set")
+	}
+
+	path = pathNormalize(path)
+	if path == "" {
+		return nil, status.Error(codes.InvalidArgument, "path must be a valid unix or depot-style path")
+	}
+
+	if err := s.ensureRepo(); err != nil {
+		return nil, err
+	}
+
+	c, err := s.repo.CommitObject(plumbing.NewHash(hash))
+	switch {
+	case err == plumbing.ErrObjectNotFound:
+		return nil, status.Error(codes.NotFound, "hash not found")
+	case err != nil:
+		return nil, status.Errorf(codes.Unavailable, "git error: %v", err)
+	}
+
+	file, err := c.File(path)
+	switch {
+	case err == object.ErrFileNotFound && !notFoundOkay:
+		return nil, status.Error(codes.NotFound, "file not found")
+	case err == object.ErrFileNotFound && notFoundOkay:
+		return nil, nil
+	case err != nil:
+		return nil, status.Errorf(codes.Unavailable, "git error: %v", err)
+	}
+
+	return file, nil
+}
+
+func (s *Service) Stat(ctx context.Context, req *pb.StatRequest) (*pb.StatResponse, error) {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+
+	file, err := s.getFile(req.Hash, req.Path, true)
+	if err != nil {
+		return nil, err
+	}
+
+	if file == nil {
+		return &pb.StatResponse{Type: pb.StatResponse_TYPE_NOT_PRESENT}, nil
+	}
+
+	switch {
+	case file.Mode == filemode.Dir:
+		return &pb.StatResponse{Type: pb.StatResponse_TYPE_DIRECTORY}, nil
+	case file.Mode.IsFile():
+		return &pb.StatResponse{Type: pb.StatResponse_TYPE_FILE}, nil
+	default:
+		return nil, status.Errorf(codes.Unimplemented, "unknown file type %o", file.Mode)
+	}
+}
+
+func (s *Service) Read(req *pb.ReadRequest, srv pb.DepotView_ReadServer) error {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+
+	file, err := s.getFile(req.Hash, req.Path, false)
+	if err != nil {
+		return err
+	}
+
+	reader, err := file.Reader()
+	if err != nil {
+		return status.Errorf(codes.Unavailable, "file read error: %v", err)
+	}
+	defer reader.Close()
+
+	ctx := srv.Context()
+	for {
+		if ctx.Err() != nil {
+			return ctx.Err()
+		}
+
+		// 1 MB read
+		chunk := make([]byte, 16*1024)
+		n, err := reader.Read(chunk)
+		switch {
+		case err == io.EOF:
+			n = 0
+		case err != nil:
+			return status.Errorf(codes.Unavailable, "file read error: %v", err)
+		}
+
+		err = srv.Send(&pb.ReadResponse{Data: chunk[:n]})
+		if err != nil {
+			return err
+		}
+
+		if n == 0 {
+			break
+		}
+	}
+
+	return nil
+}
+
+func pathNormalize(path string) string {
+	leadingSlashes := 0
+	for _, c := range path {
+		if c != '/' {
+			break
+		}
+		leadingSlashes += 1
+	}
+
+	// Only foo/bar, /foo/bar, and //foo/bar paths allowed.
+	if leadingSlashes > 2 {
+		return ""
+	}
+	path = path[leadingSlashes:]
+
+	// No trailing slashes allowed.
+	if strings.HasSuffix(path, "/") {
+		return ""
+	}
+
+	return path
+}