blob: 910bf36366537f91e2f5f3cb68593a572dfecbf3 [file] [log] [blame]
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
}