1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package gitcli
4
5import (
6 "bufio"
7 "context"
8 "errors"
9 "fmt"
10 "io"
11 "path/filepath"
12 "strconv"
13 "strings"
14
15 "bnema.dev/ermite/internal/domain"
16 "bnema.dev/ermite/internal/ports"
17)
18
19var _ ports.PushTreeReader = (*Git)(nil)
20
21const (
22 maxDiffPath = 4096
23 maxRevListLine = 8192
24)
25
26// PushSubtrees peels push endpoints, walks new commits, and reads entries at path.
27func (g *Git) PushSubtrees(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, objects ports.PushObjects, path string, bounds domain.PushWalkBounds) (domain.PushSubtrees, error) {
28 out := domain.PushSubtrees{Ends: map[string]domain.PushObjectType{}, Subtrees: map[string]domain.Subtree{}}
29 dir, err := g.repoDir(repo)
30 if err != nil {
31 return out, err
32 }
33 extra, err := quarantineEnv(dir, objects)
34 if err != nil {
35 return out, err
36 }
37 var kept []domain.RefUpdate
38 for _, up := range updates {
39 if up.New != domain.ZeroOID {
40 kept = append(kept, up)
41 }
42 }
43 if len(kept) == 0 {
44 return out, nil
45 }
46 return g.readPushSubtrees(ctx, dir, extra, kept, path, bounds, out)
47}
48
49// readPushSubtrees batches the endpoint and walked-object cat-file queries.
50func (g *Git) readPushSubtrees(ctx context.Context, dir string, extra []string, kept []domain.RefUpdate, path string, bounds domain.PushWalkBounds, out domain.PushSubtrees) (domain.PushSubtrees, error) {
51 types, err := g.batchCheck(ctx, dir, extra, kept, "^{}", "%(objecttype)")
52 if err != nil {
53 return out, err
54 }
55 tips := pushCommitTips(kept, types, out.Ends)
56 if len(tips) != 0 {
57 out.Commits, err = g.newCommits(ctx, dir, extra, tips, bounds)
58 if err != nil {
59 return out, err
60 }
61 }
62 ids := commitAndParentIDs(out.Commits)
63 ws, err := g.subtreesAt(ctx, dir, extra, ids, path)
64 if err != nil {
65 return out, err
66 }
67 for id, w := range ws {
68 out.Subtrees[id] = w
69 }
70 ids = pushEndSubtreeIDs(kept, out.Ends)
71 ws, err = g.subtreesAt(ctx, dir, extra, ids, path)
72 if err != nil {
73 return out, err
74 }
75 for id, w := range ws {
76 out.Subtrees[id] = w
77 }
78 return out, nil
79}
80
81// pushCommitTips translates peeled endpoint answers and deduplicates rev-list tips.
82func pushCommitTips(kept []domain.RefUpdate, types []string, ends map[string]domain.PushObjectType) []string {
83 var tips []string
84 seen := map[string]bool{}
85 for i, up := range kept {
86 for j, id := range []string{up.Old, up.New} {
87 if id != domain.ZeroOID {
88 ends[id] = domain.PushObjectType(types[2*i+j])
89 }
90 }
91 if types[2*i+1] == "commit" && !seen[up.New] {
92 seen[up.New] = true
93 tips = append(tips, up.New)
94 }
95 }
96 return tips
97}
98
99// commitAndParentIDs deduplicates the object names for a cat-file batch.
100func commitAndParentIDs(commits []domain.PushedCommit) []string {
101 var ids []string
102 asked := map[string]bool{}
103 for _, c := range commits {
104 for _, id := range append([]domain.ObjectID{c.ID}, c.Parents...) {
105 key := id.String()
106 if !asked[key] {
107 asked[key] = true
108 ids = append(ids, key)
109 }
110 }
111 }
112 return ids
113}
114
115// pushEndSubtreeIDs omits blobs (not tree-ish) and includes HEAD only for creates.
116func pushEndSubtreeIDs(kept []domain.RefUpdate, ends map[string]domain.PushObjectType) []string {
117 var ids []string
118 for _, up := range kept {
119 for _, id := range []string{up.Old, up.New} {
120 if id != domain.ZeroOID && ends[id] != domain.PushObjectBlob {
121 ids = append(ids, id)
122 }
123 }
124 }
125 // HEAD is needed only for a new tree-ish ref. Keep it in the end batch.
126 for _, up := range kept {
127 if up.Old == domain.ZeroOID && ends[up.New] != domain.PushObjectBlob {
128 ids = append(ids, domain.HeadKey)
129 break
130 }
131 }
132 return ids
133}
134
135// newCommits lists the commits reachable from the tips and from no ref
136// (`rev-list --parents <tips> --not --all`), at most bounds.Commits: more
137// fails closed.
138func (g *Git) newCommits(ctx context.Context, dir string, extra []string, tips []string, bounds domain.PushWalkBounds) ([]domain.PushedCommit, error) {
139 if err := revListLimitsError(bounds); err != nil {
140 return nil, err
141 }
142 args := readArgs(dir, append(append([]string{"rev-list", "--parents", "--max-count=" + strconv.Itoa(bounds.Commits+1)}, tips...), "--not", "--all")...)
143 var out []domain.PushedCommit
144 err := g.pipeWith(ctx, extra, nil, args, func(r *bufio.Reader) error {
145 var err error
146 out, err = parseRevList(r, bounds.Commits, bounds.IDs)
147 return err
148 })
149 if err != nil {
150 return nil, fmt.Errorf("commits of a push: %w", err)
151 }
152 return out, nil
153}
154
155// revListLimitsError checks the numeric range of rev-list and parser limits.
156func revListLimitsError(bounds domain.PushWalkBounds) error {
157 if bounds.Commits < 0 || bounds.IDs < 0 || bounds.Commits == int(^uint(0)>>1) {
158 return fmt.Errorf("%w: invalid push walk bounds", domain.ErrInvalid)
159 }
160 return nil
161}
162
163// parseRevList reads the lines of `rev-list --parents` ("<commit> <parent>..."):
164// at most maxCommits lines and maxIDs ids in all (a commit and each of its
165// parents count, repeats too). A line over maxRevListLine, a limit crossed or an
166// id that is not one is an error, and it is raised as soon as the line that
167// crosses it is read: nothing past it is read or kept.
168func parseRevList(r *bufio.Reader, maxCommits, maxIDs int) ([]domain.PushedCommit, error) {
169 var out []domain.PushedCommit
170 ids := 0
171 for {
172 line, err := r.ReadSlice('\n')
173 switch {
174 case errors.Is(err, bufio.ErrBufferFull) || len(line) > maxRevListLine:
175 return nil, fmt.Errorf("%w: a pushed commit has too many parents", domain.ErrInvalid)
176 case errors.Is(err, io.EOF) && len(line) == 0:
177 return out, nil
178 case err != nil && !errors.Is(err, io.EOF):
179 return nil, err
180 }
181 f := strings.Fields(string(line))
182 if len(f) == 0 {
183 continue
184 }
185 if len(out) == maxCommits {
186 return nil, fmt.Errorf("%w: the push brings more than %d commits", domain.ErrInvalid, maxCommits)
187 }
188 if ids += len(f); ids > maxIDs {
189 return nil, fmt.Errorf("%w: the push brings commits with more than %d ids (commits and parents) in all", domain.ErrInvalid, maxIDs)
190 }
191 for _, id := range f {
192 if _, err := domain.ParseObjectID(id); err != nil {
193 return nil, fmt.Errorf("%w: unexpected rev-list output", domain.ErrInvalid)
194 }
195 }
196 id, _ := domain.ParseObjectID(f[0])
197 c := domain.PushedCommit{ID: id}
198 for _, p := range f[1:] {
199 id, _ := domain.ParseObjectID(p)
200 c.Parents = append(c.Parents, id)
201 }
202 out = append(out, c)
203 }
204}
205
206// subtreesAt reads the mode, id and type of entries without traversing them.
207func (g *Git) subtreesAt(ctx context.Context, dir string, extra []string, ids []string, path string) (map[string]domain.Subtree, error) {
208 out := make(map[string]domain.Subtree, len(ids))
209 if len(ids) == 0 {
210 return out, nil
211 }
212 specs := make([]string, len(ids))
213 for i, id := range ids {
214 specs[i] = id + ":" + path
215 }
216 args := readArgs(dir, "cat-file", "--batch-check=%(objectmode) %(objectname) %(objecttype)")
217 err := g.pipeWith(ctx, extra, strings.NewReader(strings.Join(specs, "\n")+"\n"), args, func(r *bufio.Reader) error {
218 for _, key := range ids {
219 line, err := r.ReadSlice('\n')
220 if err != nil {
221 return fmt.Errorf("read object answers: %w", err)
222 }
223 f := strings.Fields(string(line))
224 if len(f) != 3 {
225 continue
226 } // missing entry
227 mode, err := strconv.ParseUint(f[0], 8, 32)
228 if err != nil {
229 continue
230 }
231 id, err := domain.ParseObjectID(f[1])
232 if err != nil {
233 return err
234 }
235 kind := domain.EntryKindOf(uint32(mode))
236 if kind == domain.EntryDirectory && (f[0] != "040000" || f[2] != "tree") {
237 kind = ""
238 }
239 out[key] = domain.Subtree{Kind: kind, ID: id}
240 }
241 return nil
242 })
243 if err != nil {
244 return nil, fmt.Errorf("subtrees of a push: %w", err)
245 }
246 return out, nil
247}
248
249// FirstDifferentEntry streams the first differing top-level entry of two trees.
250func (g *Git) FirstDifferentEntry(ctx context.Context, repo domain.RepoName, objects ports.PushObjects, oldID, newID domain.ObjectID) (string, error) {
251 dir, err := g.repoDir(repo)
252 if err != nil {
253 return "", err
254 }
255 extra, err := quarantineEnv(dir, objects)
256 if err != nil {
257 return "", err
258 }
259 from := oldID.String()
260 if from == "" {
261 from = emptyTree
262 }
263 to := newID.String()
264 args := readArgs(dir, "diff-tree", "--name-only", "-z", "--no-renames", "--no-commit-id", "--end-of-options", from, to)
265 var name string
266 err = g.pipeWith(ctx, extra, nil, args, func(r *bufio.Reader) error {
267 n, found, err := firstPath(r)
268 name = n
269 if err != nil || !found {
270 return err // no name: git's exit status is checked, so a failure refuses
271 }
272 return errStop // the first name is all that is needed: stop git
273 })
274 if err != nil {
275 return "", fmt.Errorf("different tree entries %s..%s: %w", from, to, err)
276 }
277 return name, nil
278}
279
280// firstPath reads the first NUL-terminated path of r; found is false at the end
281// of the output without one. A path longer than maxDiffPath is an error.
282func firstPath(r *bufio.Reader) (path string, found bool, err error) {
283 var b []byte
284 for {
285 c, err := r.ReadByte()
286 switch {
287 case err != nil:
288 return "", false, nil // the end of the output; a failing exit status is reported by pipe
289 case c == 0:
290 return string(b), true, nil
291 case len(b) >= maxDiffPath:
292 return "", false, fmt.Errorf("%w: a changed path is longer than %d bytes", domain.ErrInvalid, maxDiffPath)
293 }
294 b = append(b, c)
295 }
296}
297
298// batchCheck asks `cat-file --batch-check=<format>` about <oid><suffix> for the
299// old (unless the ref is created) and the new end of each update and returns
300// the answers in that order, two per update, "" for the old end of a created
301// ref. With suffix "^{}" an object that is missing, malformed or neither a
302// commit, a tree nor a blob is an error (the push is refused); with any other
303// suffix an answer "<input> missing" is kept as the answer.
304func (g *Git) batchCheck(ctx context.Context, dir string, extra []string, ups []domain.RefUpdate, suffix, format string) ([]string, error) {
305 var in strings.Builder
306 want := 0
307 for _, up := range ups {
308 for _, oid := range []string{up.Old, up.New} {
309 if oid == domain.ZeroOID {
310 continue
311 }
312 if _, err := domain.ParseObjectID(oid); err != nil {
313 return nil, fmt.Errorf("%w: %v", domain.ErrInvalid, err)
314 }
315 in.WriteString(oid + suffix + "\n")
316 want++
317 }
318 }
319 got := make([]string, 0, want)
320 args := readArgs(dir, "cat-file", "--batch-check="+format)
321 err := g.pipeWith(ctx, extra, strings.NewReader(in.String()), args, func(r *bufio.Reader) error {
322 for range want {
323 line, err := r.ReadSlice('\n')
324 if err != nil {
325 if errors.Is(err, bufio.ErrBufferFull) {
326 return fmt.Errorf("%w: unexpected cat-file output", domain.ErrInvalid)
327 }
328 return fmt.Errorf("read object answers: %w", err)
329 }
330 got = append(got, strings.TrimSuffix(string(line), "\n"))
331 }
332 return nil
333 })
334 if err != nil {
335 return nil, fmt.Errorf("objects of a push: %w", err)
336 }
337 out := make([]string, 0, 2*len(ups))
338 i := 0
339 for _, up := range ups {
340 for _, oid := range []string{up.Old, up.New} {
341 if oid == domain.ZeroOID {
342 out = append(out, "")
343 continue
344 }
345 if suffix == "^{}" {
346 switch got[i] {
347 case "commit", "tree", "blob":
348 default: // "<input> missing", or a type that cannot be compared
349 return nil, fmt.Errorf("%w: object %s of the push is missing or of an unexpected type", domain.ErrInvalid, oid)
350 }
351 }
352 out = append(out, got[i])
353 i++
354 }
355 }
356 return out, nil
357}
358
359// quarantineEnv returns the environment that makes git read the objects of a
360// push in quarantine. Each directory must exist and, once symbolic links are
361// resolved, be inside the objects directory of the repository dir (itself
362// resolved): a hook request cannot send git to read anything else. The resolved
363// paths are the ones given to git.
364func quarantineEnv(repoDir string, o ports.PushObjects) ([]string, error) {
365 if o.Dir == "" && o.Alternates == "" {
366 return nil, nil
367 }
368 root, err := filepath.EvalSymlinks(filepath.Join(repoDir, "objects"))
369 if err != nil {
370 return nil, fmt.Errorf("resolve the objects directory of %s: %w", repoDir, err)
371 }
372 inside := func(p string) (string, bool) {
373 if !filepath.IsAbs(p) {
374 return "", false
375 }
376 real, err := filepath.EvalSymlinks(p)
377 if err != nil {
378 return "", false
379 }
380 rel, err := filepath.Rel(root, real)
381 return real, err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
382 }
383 dir, ok := inside(o.Dir)
384 if o.Dir == "" || !ok {
385 return nil, fmt.Errorf("%w: the objects directory of the push is outside the repository", domain.ErrInvalid)
386 }
387 env := []string{"GIT_OBJECT_DIRECTORY=" + dir}
388 if o.Alternates != "" {
389 var alts []string
390 for _, a := range filepath.SplitList(o.Alternates) {
391 real, ok := inside(a)
392 if !ok {
393 return nil, fmt.Errorf("%w: an alternate objects directory of the push is outside the repository", domain.ErrInvalid)
394 }
395 alts = append(alts, real)
396 }
397 env = append(env, "GIT_ALTERNATE_OBJECT_DIRECTORIES="+strings.Join(alts, string(filepath.ListSeparator)))
398 }
399 return env, nil
400}