blob: 910bf36366537f91e2f5f3cb68593a572dfecbf3 [file] [log] [blame]
Sergiusz Bazanski4c0e9b52020-04-08 22:42:33 +02001package service
2
3import (
4 "context"
5 "io"
6 "regexp"
7 "strings"
8 "sync"
9 "time"
10
11 "github.com/golang/glog"
12 "google.golang.org/grpc/codes"
13 "google.golang.org/grpc/status"
14
15 git "github.com/go-git/go-git/v5"
16 "github.com/go-git/go-git/v5/plumbing"
17 "github.com/go-git/go-git/v5/plumbing/filemode"
18 "github.com/go-git/go-git/v5/plumbing/object"
19 "github.com/go-git/go-git/v5/storage"
20 "github.com/go-git/go-git/v5/storage/memory"
21
22 pb "code.hackerspace.pl/hscloud/devtools/depotview/proto"
23)
24
25var (
26 reHash = regexp.MustCompile(`[a-f0-9]{40,64}`)
27)
28
29type Service struct {
30 remote string
31 storer storage.Storer
32
33 mu sync.Mutex
34 repo *git.Repository
35 lastPull time.Time
36}
37
38func New(remote string) *Service {
39 return &Service{
40 remote: remote,
41 storer: memory.NewStorage(),
42 }
43}
44
45func (s *Service) ensureRepo() error {
46 // Clone repository if necessary.
47 if s.repo == nil {
48 repo, err := git.Clone(s.storer, nil, &git.CloneOptions{
49 URL: s.remote,
50 })
51 if err != nil {
52 glog.Errorf("Clone(%q): %v", s.remote, err)
53 return status.Error(codes.Unavailable, "could not clone repository")
54 }
55 s.repo = repo
56 s.lastPull = time.Now()
57 }
58
59 // Fetch if necessary.
60 if time.Since(s.lastPull) > time.Minute {
61 err := s.repo.Fetch(&git.FetchOptions{})
62 if err != nil && err != git.NoErrAlreadyUpToDate {
63 glog.Errorf("Fetch(): %v", err)
64 } else {
65 s.lastPull = time.Now()
66 }
67 }
68
69 return nil
70}
71
72func (s *Service) Resolve(ctx context.Context, req *pb.ResolveRequest) (*pb.ResolveResponse, error) {
73 s.mu.Lock()
74 defer s.mu.Unlock()
75
76 if req.Ref == "" {
77 return nil, status.Error(codes.InvalidArgument, "ref must be set")
78 }
79
80 if err := s.ensureRepo(); err != nil {
81 return nil, err
82 }
83
84 h, err := s.repo.ResolveRevision(plumbing.Revision(req.Ref))
85 switch {
86 case err == plumbing.ErrReferenceNotFound:
87 return &pb.ResolveResponse{Hash: "", LastChecked: s.lastPull.UnixNano()}, nil
88 case err != nil:
89 return nil, status.Errorf(codes.Unavailable, "git resolve error: %v", err)
90 default:
91 return &pb.ResolveResponse{Hash: h.String(), LastChecked: s.lastPull.UnixNano()}, nil
92 }
93}
94
95func (s *Service) getFile(hash, path string, notFoundOkay bool) (*object.File, error) {
96 if !reHash.MatchString(hash) {
97 return nil, status.Error(codes.InvalidArgument, "hash must be valid full git hash string")
98 }
99 if path == "" {
100 return nil, status.Error(codes.InvalidArgument, "path must be set")
101 }
102
103 path = pathNormalize(path)
104 if path == "" {
105 return nil, status.Error(codes.InvalidArgument, "path must be a valid unix or depot-style path")
106 }
107
108 if err := s.ensureRepo(); err != nil {
109 return nil, err
110 }
111
112 c, err := s.repo.CommitObject(plumbing.NewHash(hash))
113 switch {
114 case err == plumbing.ErrObjectNotFound:
115 return nil, status.Error(codes.NotFound, "hash not found")
116 case err != nil:
117 return nil, status.Errorf(codes.Unavailable, "git error: %v", err)
118 }
119
120 file, err := c.File(path)
121 switch {
122 case err == object.ErrFileNotFound && !notFoundOkay:
123 return nil, status.Error(codes.NotFound, "file not found")
124 case err == object.ErrFileNotFound && notFoundOkay:
125 return nil, nil
126 case err != nil:
127 return nil, status.Errorf(codes.Unavailable, "git error: %v", err)
128 }
129
130 return file, nil
131}
132
133func (s *Service) Stat(ctx context.Context, req *pb.StatRequest) (*pb.StatResponse, error) {
134 s.mu.Lock()
135 defer s.mu.Unlock()
136
137 file, err := s.getFile(req.Hash, req.Path, true)
138 if err != nil {
139 return nil, err
140 }
141
142 if file == nil {
143 return &pb.StatResponse{Type: pb.StatResponse_TYPE_NOT_PRESENT}, nil
144 }
145
146 switch {
147 case file.Mode == filemode.Dir:
148 return &pb.StatResponse{Type: pb.StatResponse_TYPE_DIRECTORY}, nil
149 case file.Mode.IsFile():
150 return &pb.StatResponse{Type: pb.StatResponse_TYPE_FILE}, nil
151 default:
152 return nil, status.Errorf(codes.Unimplemented, "unknown file type %o", file.Mode)
153 }
154}
155
156func (s *Service) Read(req *pb.ReadRequest, srv pb.DepotView_ReadServer) error {
157 s.mu.Lock()
158 defer s.mu.Unlock()
159
160 file, err := s.getFile(req.Hash, req.Path, false)
161 if err != nil {
162 return err
163 }
164
165 reader, err := file.Reader()
166 if err != nil {
167 return status.Errorf(codes.Unavailable, "file read error: %v", err)
168 }
169 defer reader.Close()
170
171 ctx := srv.Context()
172 for {
173 if ctx.Err() != nil {
174 return ctx.Err()
175 }
176
177 // 1 MB read
178 chunk := make([]byte, 16*1024)
179 n, err := reader.Read(chunk)
180 switch {
181 case err == io.EOF:
182 n = 0
183 case err != nil:
184 return status.Errorf(codes.Unavailable, "file read error: %v", err)
185 }
186
187 err = srv.Send(&pb.ReadResponse{Data: chunk[:n]})
188 if err != nil {
189 return err
190 }
191
192 if n == 0 {
193 break
194 }
195 }
196
197 return nil
198}
199
200func pathNormalize(path string) string {
201 leadingSlashes := 0
202 for _, c := range path {
203 if c != '/' {
204 break
205 }
206 leadingSlashes += 1
207 }
208
209 // Only foo/bar, /foo/bar, and //foo/bar paths allowed.
210 if leadingSlashes > 2 {
211 return ""
212 }
213 path = path[leadingSlashes:]
214
215 // No trailing slashes allowed.
216 if strings.HasSuffix(path, "/") {
217 return ""
218 }
219
220 return path
221}