1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package readertest
4
5import (
6 "context"
7 "encoding/hex"
8 "errors"
9 "fmt"
10 "io"
11 "strings"
12 "testing"
13 "time"
14
15 "github.com/stretchr/testify/assert"
16 "github.com/stretchr/testify/require"
17
18 "bnema.dev/ermite/internal/domain"
19 "bnema.dev/ermite/internal/ports"
20)
21
22// Maker builds a RepoReader serving the repositories below data/repos, and
23// returns it with the data directory. The reader is closed when the test ends.
24type Maker func(t *testing.T) (ports.RepoReader, string)
25
26// Conformance runs the RepoReader contract against the readers mk builds:
27// every adapter that serves repositories must pass it, whatever its route.
28func Conformance(t *testing.T, mk Maker) {
29 for _, tc := range []struct {
30 name string
31 fn func(*testing.T, Maker)
32 }{
33 {"ListRefs", testListRefs},
34 {"ListRefsEmptyAndMissing", testListRefsEmptyAndMissing},
35 {"DetachedHead", testDetachedHead},
36 {"ResolveRevision", testResolveRevision},
37 {"ResolveRefPath", testResolveRefPath},
38 {"GetTree", testGetTree},
39 {"GetTreeLastCommit", testGetTreeLastCommit},
40 {"GetTreeLastCommitBound", testGetTreeLastCommitBound},
41 {"ListCommits", testListCommits},
42 {"OpenBlob", testOpenBlob},
43 {"OpenBlobMediaTypes", testOpenBlobMediaTypes},
44 {"BinaryRuleLooksAtFirst8000Bytes", testBinaryRuleLooksAtFirst8000Bytes},
45 {"OpenBlobLargeThroughStream", testOpenBlobLargeThroughStream},
46 {"CancelledContext", testCancelledContext},
47 {"HostileRevisionAndPathStayLiteral", testHostileRevisionAndPathStayLiteral},
48 {"ResolveRevisionObjectIDBeatsRefs", testResolveRevisionObjectIDBeatsRefs},
49 {"GetTreeIsBounded", testGetTreeIsBounded},
50 {"GetTreeRefusesAGitlinkToATree", testGetTreeRefusesAGitlinkToATree},
51 {"ListPaths", testListPaths},
52 {"CountCommitsAndLatestTag", testCountCommitsAndLatestTag},
53 } {
54 t.Run(tc.name, func(t *testing.T) { tc.fn(t, mk) })
55 }
56}
57
58// Rev parses a revision.
59func Rev(t *testing.T, s string) domain.Revision {
60 t.Helper()
61 r, err := domain.ParseRevision(s)
62 require.NoError(t, err)
63 return r
64}
65
66// TreePath parses a tree path.
67func TreePath(t *testing.T, s string) domain.TreePath {
68 t.Helper()
69 p, err := domain.ParseTreePath(s)
70 require.NoError(t, err)
71 return p
72}
73
74func testListRefs(t *testing.T, mk Maker) {
75 ctx := context.Background()
76 g, data := mk(t)
77 h := NewHistory(t, data)
78
79 refs, err := g.ListRefs(ctx, h.Name)
80 require.NoError(t, err)
81
82 type row struct {
83 name string
84 short string
85 kind domain.RefKind
86 id string
87 commi string
88 }
89 var got []row
90 for _, r := range refs {
91 got = append(got, row{r.Name, r.ShortName, r.Kind, r.ID.String(), r.CommitID.String()})
92 }
93 tagID := func(n string) string { return h.Git("rev-parse", "refs/tags/"+n) }
94 assert.Equal(t, []row{
95 {"HEAD", "main", domain.RefKindHead, h.Merge, h.Merge},
96 {"refs/heads/feature/x", "feature/x", domain.RefKindBranch, h.Side, h.Side},
97 {"refs/heads/main", "main", domain.RefKindBranch, h.Merge, h.Merge},
98 {"refs/heads/rel/1", "rel/1", domain.RefKindBranch, h.C1, h.C1},
99 {"refs/heads/release/1.0", "release/1.0", domain.RefKindBranch, h.C2, h.C2},
100 {"refs/tags/rel/1/x", "rel/1/x", domain.RefKindTag, h.C3, h.C3},
101 {"refs/tags/tree-tag", "tree-tag", domain.RefKindTag, tagID("tree-tag"), ""},
102 {"refs/tags/v1", "v1", domain.RefKindTag, h.C1, h.C1},
103 {"refs/tags/v2", "v2", domain.RefKindTag, tagID("v2"), h.C2},
104 {"refs/tags/v2-again", "v2-again", domain.RefKindTag, tagID("v2-again"), h.C2},
105 }, got)
106}
107
108func testCountCommitsAndLatestTag(t *testing.T, mk Maker) {
109 ctx := context.Background()
110 g, data := mk(t)
111 f := NewFixture(t, data, "stats")
112
113 _, ok, err := g.LatestTag(ctx, f.Name)
114 require.NoError(t, err)
115 assert.False(t, ok, "no tag yet")
116
117 var ids []string
118 for i := range 4 { // committed at 12:01, 12:02, 12:03, 12:04
119 ids = append(ids, f.Commit("c"+itoa(i)))
120 }
121 head := MustID(t, ids[3])
122 n, err := g.CountCommits(ctx, f.Name, head, time.Time{})
123 require.NoError(t, err)
124 assert.Equal(t, 4, n)
125 n, err = g.CountCommits(ctx, f.Name, head, f.When.Add(-90*time.Second))
126 require.NoError(t, err)
127 assert.Equal(t, 2, n, "the commits of 12:03 and 12:04")
128 n, err = g.CountCommits(ctx, f.Name, MustID(t, ids[1]), time.Time{})
129 require.NoError(t, err)
130 assert.Equal(t, 2, n, "from another commit")
131 _, err = g.CountCommits(ctx, f.Name, MustID(t, strings.Repeat("ab", 20)), time.Time{})
132 assert.ErrorIs(t, err, domain.ErrNotFound)
133
134 // Dates out of order: a commit dated in the past on top of recent ones
135 // does not hide them.
136 recent := f.When
137 f.When = recent.Add(-48 * time.Hour)
138 old := f.Commit("backdated")
139 f.When = recent
140 n, err = g.CountCommits(ctx, f.Name, MustID(t, old), recent.Add(-90*time.Second))
141 require.NoError(t, err)
142 assert.Equal(t, 2, n, "the recent commits behind an old one count")
143
144 // The latest tag is the one made last: an annotated tag by its own date,
145 // a lightweight one by the date of its commit.
146 f.Git("tag", "light-old", ids[0])
147 f.When = f.When.Add(time.Hour)
148 f.Git("tag", "-a", "-m", "new", "v2", ids[0])
149 f.Git("tag", "light-new", ids[3])
150 tag, ok, err := g.LatestTag(ctx, f.Name)
151 require.NoError(t, err)
152 require.True(t, ok)
153 assert.Equal(t, "v2", tag.Name)
154 assert.True(t, tag.At.Equal(f.When), "%v", tag.At)
155 assert.Equal(t, ids[0], tag.CommitID.String(), "an annotated tag is peeled to its commit")
156
157 f.When = f.When.Add(time.Hour)
158 f.Git("tag", "-a", "-m", "tree", "tree-tag", ids[0]+"^{tree}")
159 tag, ok, err = g.LatestTag(ctx, f.Name)
160 require.NoError(t, err)
161 require.True(t, ok)
162 assert.Equal(t, "tree-tag", tag.Name)
163 assert.True(t, tag.CommitID.IsZero(), "a tag of a tree has no commit")
164
165 _, _, err = g.LatestTag(ctx, RepoName(t, "nope"))
166 assert.ErrorIs(t, err, domain.ErrNotFound)
167}
168
169func testListRefsEmptyAndMissing(t *testing.T, mk Maker) {
170 ctx := context.Background()
171 g, data := mk(t)
172 f := NewFixture(t, data, "empty")
173
174 refs, err := g.ListRefs(ctx, f.Name)
175 require.NoError(t, err)
176 require.Len(t, refs, 1)
177 assert.Equal(t, "HEAD", refs[0].Name)
178 assert.Equal(t, "main", refs[0].ShortName, "unborn default branch")
179 assert.True(t, refs[0].ID.IsZero())
180 assert.True(t, refs[0].CommitID.IsZero())
181
182 _, err = g.ListRefs(ctx, RepoName(t, "nope"))
183 assert.ErrorIs(t, err, domain.ErrNotFound)
184
185 // No commit to resolve in an empty repository.
186 _, err = g.ResolveRevision(ctx, f.Name, domain.Revision{})
187 assert.ErrorIs(t, err, domain.ErrNotFound)
188 _, err = g.GetTree(ctx, f.Name, domain.Revision{}, domain.TreePath{}, false)
189 assert.ErrorIs(t, err, domain.ErrNotFound)
190 _, err = g.ListCommits(ctx, f.Name, ports.CommitQuery{Limit: 10})
191 assert.ErrorIs(t, err, domain.ErrNotFound)
192}
193
194func testDetachedHead(t *testing.T, mk Maker) {
195 ctx := context.Background()
196 g, data := mk(t)
197 h := NewHistory(t, data)
198 h.Git("update-ref", "--no-deref", "HEAD", h.C1)
199
200 refs, err := g.ListRefs(ctx, h.Name)
201 require.NoError(t, err)
202 assert.Equal(t, "HEAD", refs[0].Name)
203 assert.Empty(t, refs[0].ShortName)
204}
205
206func testResolveRevision(t *testing.T, mk Maker) {
207 ctx := context.Background()
208 g, data := mk(t)
209 h := NewHistory(t, data)
210
211 tests := []struct {
212 rev string
213 commit string
214 ref string
215 }{
216 {"", h.Merge, ""},
217 {"HEAD", h.Merge, ""},
218 {"main", h.Merge, "refs/heads/main"},
219 {"feature/x", h.Side, "refs/heads/feature/x"},
220 {"refs/heads/feature/x", h.Side, "refs/heads/feature/x"},
221 {"v1", h.C1, "refs/tags/v1"},
222 {"v2", h.C2, "refs/tags/v2"},
223 {"v2-again", h.C2, "refs/tags/v2-again"},
224 {"refs/tags/v2", h.C2, "refs/tags/v2"},
225 {h.C2, h.C2, ""},
226 {h.C2[:7], h.C2, ""},
227 {h.C2[:12], h.C2, ""},
228 }
229 for _, tt := range tests {
230 t.Run(tt.rev, func(t *testing.T) {
231 got, err := g.ResolveRevision(ctx, h.Name, Rev(t, tt.rev))
232 require.NoError(t, err)
233 assert.Equal(t, tt.commit, got.CommitID.String())
234 assert.Equal(t, tt.ref, got.Ref)
235 })
236 }
237 for _, bad := range []string{"nope", "tree-tag", "deadbeefdeadbeef", "HEAD~1", h.C2[:6], "refs/heads", h.Git("rev-parse", h.Merge+"^{tree}")} {
238 t.Run("missing "+bad, func(t *testing.T) {
239 r, err := domain.ParseRevision(bad)
240 if err != nil {
241 return // not a revision at all, rejected before the reader
242 }
243 _, err = g.ResolveRevision(ctx, h.Name, r)
244 assert.ErrorIs(t, err, domain.ErrNotFound)
245 })
246 }
247
248 t.Run("a branch wins over a tag of the same name", func(t *testing.T) {
249 h.Git("branch", "both", h.C1)
250 h.Git("tag", "both", h.C2)
251 got, err := g.ResolveRevision(ctx, h.Name, Rev(t, "both"))
252 require.NoError(t, err)
253 assert.Equal(t, h.C1, got.CommitID.String())
254 })
255}
256
257func testResolveRefPath(t *testing.T, mk Maker) {
258 ctx := context.Background()
259 g, data := mk(t)
260 h := NewHistory(t, data)
261
262 tests := []struct {
263 path string
264 name string
265 ref string
266 id string
267 rest string
268 }{
269 {"main", "main", "refs/heads/main", h.Merge, ""},
270 {"main/d/f", "main", "refs/heads/main", h.Merge, "d/f"},
271 {"feature/x", "feature/x", "refs/heads/feature/x", h.Side, ""},
272 {"feature/x/d/f", "feature/x", "refs/heads/feature/x", h.Side, "d/f"},
273 {"feature/x/", "feature/x", "refs/heads/feature/x", h.Side, ""},
274 {"release/1.0/a.txt", "release/1.0", "refs/heads/release/1.0", h.C2, "a.txt"},
275 // The longest ref wins: the tag rel/1/x over the branch rel/1.
276 {"rel/1/x/d/f", "rel/1/x", "refs/tags/rel/1/x", h.C3, "d/f"},
277 {"rel/1/d/f", "rel/1", "refs/heads/rel/1", h.C1, "d/f"},
278 {"v2/we:ird*.txt", "v2", "refs/tags/v2", h.C2, "we:ird*.txt"},
279 {h.C1 + "/d", h.C1, "", h.C1, "d"},
280 {h.C1[:8] + "/d/f", h.C1[:8], "", h.C1, "d/f"},
281 }
282 for _, tt := range tests {
283 t.Run(tt.path, func(t *testing.T) {
284 got, err := g.ResolveRefPath(ctx, h.Name, tt.path)
285 require.NoError(t, err)
286 assert.Equal(t, tt.name, got.Name)
287 assert.Equal(t, tt.ref, got.Ref)
288 assert.Equal(t, tt.id, got.CommitID.String())
289 assert.Equal(t, tt.rest, got.Rest.String())
290 })
291 }
292 for _, bad := range []string{"", "/", "nope", "nope/d/f", "a//b", "../x", "d/f"} {
293 t.Run("missing "+bad, func(t *testing.T) {
294 _, err := g.ResolveRefPath(ctx, h.Name, bad)
295 assert.ErrorIs(t, err, domain.ErrNotFound)
296 })
297 }
298 _, err := g.ResolveRefPath(ctx, RepoName(t, "nope"), "main")
299 assert.ErrorIs(t, err, domain.ErrNotFound)
300}
301
302func testGetTree(t *testing.T, mk Maker) {
303 ctx := context.Background()
304 g, data := mk(t)
305 h := NewHistory(t, data)
306
307 t.Run("root at HEAD", func(t *testing.T) {
308 tree, err := g.GetTree(ctx, h.Name, domain.Revision{}, domain.TreePath{}, false)
309 require.NoError(t, err)
310 assert.Equal(t, h.Merge, tree.CommitID.String())
311 assert.Equal(t, h.Git("rev-parse", h.Merge+"^{tree}"), tree.ID.String())
312 type row struct {
313 name, path string
314 kind domain.EntryKind
315 mode uint32
316 size int64
317 }
318 var got []row
319 for _, e := range tree.Entries {
320 got = append(got, row{e.Name, e.Path, e.Kind, e.Mode, e.Size})
321 assert.Nil(t, e.LastCommit)
322 }
323 assert.Equal(t, []row{
324 {"d", "d", domain.EntryDirectory, 0o040000, 0},
325 {"b.txt", "b.txt", domain.EntryFile, 0o100644, 19},
326 {"bin", "bin", domain.EntryFile, 0o100644, 2},
327 {"empty", "empty", domain.EntryFile, 0o100644, 0},
328 {"main.txt", "main.txt", domain.EntryFile, 0o100644, 2},
329 {"n.txt", "n.txt", domain.EntrySymlink, 0o120000, 5},
330 {"side.txt", "side.txt", domain.EntryFile, 0o100644, 2},
331 {"we:ird*.txt", "we:ird*.txt", domain.EntryFile, 0o100644, 1},
332 }, got)
333 assert.False(t, tree.LastCommitsOmitted)
334 })
335
336 t.Run("subdirectory with an executable", func(t *testing.T) {
337 tree, err := g.GetTree(ctx, h.Name, Rev(t, "v2"), TreePath(t, "d"), false)
338 require.NoError(t, err)
339 require.Len(t, tree.Entries, 2)
340 assert.Equal(t, "sub", tree.Entries[0].Name)
341 assert.Equal(t, "d/sub", tree.Entries[0].Path)
342 assert.Equal(t, domain.EntryExecutable, tree.Entries[1].Kind)
343 assert.Equal(t, uint32(0o100755), tree.Entries[1].Mode)
344 assert.Equal(t, "d/f", tree.Entries[1].Path)
345 assert.Equal(t, h.C2, tree.CommitID.String())
346 })
347
348 t.Run("errors", func(t *testing.T) {
349 _, err := g.GetTree(ctx, h.Name, domain.Revision{}, TreePath(t, "nope"), false)
350 assert.ErrorIs(t, err, domain.ErrNotFound)
351 _, err = g.GetTree(ctx, h.Name, domain.Revision{}, TreePath(t, "d/nope/x"), false)
352 assert.ErrorIs(t, err, domain.ErrNotFound)
353 _, err = g.GetTree(ctx, h.Name, domain.Revision{}, TreePath(t, "b.txt"), false)
354 assert.ErrorIs(t, err, domain.ErrNotDirectory)
355 _, err = g.GetTree(ctx, h.Name, Rev(t, "nope"), domain.TreePath{}, false)
356 assert.ErrorIs(t, err, domain.ErrNotFound)
357 })
358
359 t.Run("paths are literal", func(t *testing.T) {
360 tree, err := g.GetTree(ctx, h.Name, domain.Revision{}, TreePath(t, "d"), true)
361 require.NoError(t, err)
362 require.Len(t, tree.Entries, 2)
363 _, err = g.GetTree(ctx, h.Name, domain.Revision{}, TreePath(t, "*"), false)
364 assert.ErrorIs(t, err, domain.ErrNotFound)
365 })
366}
367
368func testGetTreeLastCommit(t *testing.T, mk Maker) {
369 ctx := context.Background()
370 g, data := mk(t)
371 h := NewHistory(t, data)
372
373 tree, err := g.GetTree(ctx, h.Name, Rev(t, "main"), domain.TreePath{}, true)
374 require.NoError(t, err)
375 got := map[string]string{}
376 for _, e := range tree.Entries {
377 require.NotNil(t, e.LastCommit, e.Path)
378 got[e.Path] = e.LastCommit.Summary
379 // The oracle: git log -1 for the same path.
380 assert.Equal(t, h.Git("log", "-1", "--format=%H", h.Merge, "--", e.Path), e.LastCommit.ID.String(), e.Path)
381 }
382 assert.Equal(t, "second", got["d"], "chmod of d/f is the last change of d")
383 assert.Equal(t, "second", got["b.txt"])
384 assert.Equal(t, "first", got["we:ird*.txt"])
385 assert.Equal(t, "third: type change", got["n.txt"])
386 assert.Equal(t, "side work", got["side.txt"])
387 assert.Equal(t, "Ada Lovelace", tree.Entries[0].LastCommit.Author.Name)
388}
389
390func testGetTreeLastCommitBound(t *testing.T, mk Maker) {
391 ctx := context.Background()
392 g, data := mk(t)
393 f := NewFixture(t, data, "wide")
394 n := domain.MaxLastCommitEntries + 5
395 for i := range n {
396 f.Write("f"+strings.Repeat("0", 3-len(itoa(i)))+itoa(i), "x\n")
397 }
398 f.Commit("wide")
399
400 tree, err := g.GetTree(ctx, f.Name, domain.Revision{}, domain.TreePath{}, true)
401 require.NoError(t, err)
402 require.Len(t, tree.Entries, n)
403 assert.True(t, tree.LastCommitsOmitted)
404 with := 0
405 for _, e := range tree.Entries {
406 if e.LastCommit != nil {
407 with++
408 }
409 }
410 assert.Equal(t, domain.MaxLastCommitEntries, with)
411 assert.NotNil(t, tree.Entries[0].LastCommit)
412 assert.Nil(t, tree.Entries[n-1].LastCommit)
413
414 tree, err = g.GetTree(ctx, f.Name, domain.Revision{}, domain.TreePath{}, false)
415 require.NoError(t, err)
416 assert.False(t, tree.LastCommitsOmitted)
417}
418
419func itoa(i int) string {
420 if i == 0 {
421 return "0"
422 }
423 var b []byte
424 for ; i > 0; i /= 10 {
425 b = append([]byte{byte('0' + i%10)}, b...)
426 }
427 return string(b)
428}
429
430func testListCommits(t *testing.T, mk Maker) {
431 ctx := context.Background()
432 g, data := mk(t)
433 h := NewHistory(t, data)
434
435 oracle := strings.Split(h.Git("log", "--format=%H", h.Merge), "\n")
436 require.Len(t, oracle, 6)
437
438 t.Run("all", func(t *testing.T) {
439 page, err := g.ListCommits(ctx, h.Name, ports.CommitQuery{Limit: 100})
440 require.NoError(t, err)
441 assert.Equal(t, h.Merge, page.Head.String())
442 assert.False(t, page.HasMore)
443 var ids []string
444 for _, c := range page.Commits {
445 ids = append(ids, c.ID.String())
446 }
447 assert.Equal(t, oracle, ids)
448 })
449
450 t.Run("pages", func(t *testing.T) {
451 var ids []string
452 skip := 0
453 for {
454 page, err := g.ListCommits(ctx, h.Name, ports.CommitQuery{Skip: skip, Limit: 2})
455 require.NoError(t, err)
456 for _, c := range page.Commits {
457 ids = append(ids, c.ID.String())
458 }
459 skip += len(page.Commits)
460 if !page.HasMore {
461 break
462 }
463 require.Len(t, page.Commits, 2)
464 }
465 assert.Equal(t, oracle, ids)
466 })
467
468 t.Run("path", func(t *testing.T) {
469 page, err := g.ListCommits(ctx, h.Name, ports.CommitQuery{Path: TreePath(t, "d/f"), Limit: 10})
470 require.NoError(t, err)
471 var ids []string
472 for _, c := range page.Commits {
473 ids = append(ids, c.ID.String())
474 }
475 assert.Equal(t, strings.Split(h.Git("log", "--format=%H", h.Merge, "--", "d/f"), "\n"), ids)
476 assert.Len(t, ids, 2)
477
478 page, err = g.ListCommits(ctx, h.Name, ports.CommitQuery{Path: TreePath(t, "we:ird*.txt"), Limit: 10})
479 require.NoError(t, err)
480 require.Len(t, page.Commits, 1, "paths are literal")
481 page, err = g.ListCommits(ctx, h.Name, ports.CommitQuery{Path: TreePath(t, "*"), Limit: 10})
482 require.NoError(t, err)
483 assert.Empty(t, page.Commits)
484 })
485
486 t.Run("revision", func(t *testing.T) {
487 page, err := g.ListCommits(ctx, h.Name, ports.CommitQuery{Revision: Rev(t, "v2"), Limit: 10})
488 require.NoError(t, err)
489 require.Len(t, page.Commits, 2)
490 assert.Equal(t, h.C2, page.Head.String())
491 assert.Equal(t, h.C2, page.Commits[0].ID.String())
492 })
493
494 t.Run("metadata", func(t *testing.T) {
495 page, err := g.ListCommits(ctx, h.Name, ports.CommitQuery{Revision: domain.RevisionOf(MustID(t, h.C1)), Limit: 10})
496 require.NoError(t, err)
497 require.Len(t, page.Commits, 1)
498 c := page.Commits[0]
499 assert.Empty(t, c.Parents)
500 assert.Equal(t, "first", c.Summary)
501 assert.Equal(t, "first\n\nbody of the first commit", c.Message)
502 assert.Equal(t, domain.Signature{Name: "Ada Lovelace", Email: "ada@example.org", Time: time.Date(2026, 1, 1, 12, 1, 0, 0, time.UTC)}, c.Author)
503 assert.Equal(t, "Grace Hopper", c.Committer.Name)
504 assert.Equal(t, "grace@example.org", c.Committer.Email)
505
506 page, err = g.ListCommits(ctx, h.Name, ports.CommitQuery{Limit: 1})
507 require.NoError(t, err)
508 require.Len(t, page.Commits, 1)
509 assert.Equal(t, []string{h.Git("rev-parse", h.Merge+"^1"), h.Side}, idStrings(page.Commits[0].Parents))
510 })
511
512 t.Run("bad queries", func(t *testing.T) {
513 for _, q := range []ports.CommitQuery{{Limit: 0}, {Limit: -1}, {Limit: ports.MaxCommitLimit + 1}, {Limit: 1, Skip: -1}} {
514 _, err := g.ListCommits(ctx, h.Name, q)
515 assert.ErrorIs(t, err, domain.ErrInvalidPageSize, "%+v", q)
516 }
517 _, err := g.ListCommits(ctx, h.Name, ports.CommitQuery{Revision: Rev(t, "nope"), Limit: 1})
518 assert.ErrorIs(t, err, domain.ErrNotFound)
519 _, err = g.ListCommits(ctx, RepoName(t, "nope"), ports.CommitQuery{Limit: 1})
520 assert.ErrorIs(t, err, domain.ErrNotFound)
521 })
522}
523
524// MustID parses an object id.
525func MustID(t *testing.T, s string) domain.ObjectID {
526 t.Helper()
527 id, err := domain.ParseObjectID(s)
528 require.NoError(t, err)
529 return id
530}
531
532func idStrings(ids []domain.ObjectID) []string {
533 out := make([]string, len(ids))
534 for i, id := range ids {
535 out[i] = id.String()
536 }
537 return out
538}
539
540func testOpenBlob(t *testing.T, mk Maker) {
541 ctx := context.Background()
542 g, data := mk(t)
543 h := NewHistory(t, data)
544
545 read := func(t *testing.T, r string, p string) (domain.BlobInfo, string) {
546 t.Helper()
547 info, rc, err := g.OpenBlob(ctx, h.Name, Rev(t, r), TreePath(t, p))
548 require.NoError(t, err)
549 body, err := io.ReadAll(rc)
550 require.NoError(t, err)
551 require.NoError(t, rc.Close())
552 return info, string(body)
553 }
554
555 info, body := read(t, "main", "b.txt")
556 assert.Equal(t, "one\ntwo\nthree\nfour\n", body)
557 assert.Equal(t, int64(19), info.Size)
558 assert.Equal(t, "b.txt", info.Path)
559 assert.Equal(t, uint32(0o100644), info.Mode)
560 assert.False(t, info.Binary)
561 assert.Equal(t, "text/plain", info.MediaType)
562 assert.Equal(t, h.Git("rev-parse", "main:b.txt"), info.ID.String())
563
564 info, body = read(t, "main", "bin")
565 assert.Equal(t, "\x00\x02", body)
566 assert.True(t, info.Binary)
567 assert.Equal(t, "application/octet-stream", info.MediaType)
568
569 info, body = read(t, "main", "empty")
570 assert.Empty(t, body)
571 assert.Equal(t, int64(0), info.Size)
572 assert.False(t, info.Binary)
573
574 info, body = read(t, "main", "n.txt")
575 assert.Equal(t, "b.txt", body, "a symlink's content is its target")
576 assert.Equal(t, uint32(0o120000), info.Mode)
577
578 info, _ = read(t, "main", "d/f")
579 assert.Equal(t, uint32(0o100755), info.Mode)
580
581 info, _ = read(t, "v1", "a.txt")
582 assert.Equal(t, "text/plain", info.MediaType)
583
584 for p, want := range map[string]error{"d": domain.ErrNotFile, "nope": domain.ErrNotFound, "d/nope": domain.ErrNotFound, "": domain.ErrNotFile} {
585 _, rc, err := g.OpenBlob(ctx, h.Name, domain.Revision{}, TreePath(t, p))
586 assert.ErrorIs(t, err, want, p)
587 assert.Nil(t, rc)
588 }
589 _, _, err := g.OpenBlob(ctx, h.Name, Rev(t, "nope"), TreePath(t, "b.txt"))
590 assert.ErrorIs(t, err, domain.ErrNotFound)
591}
592
593func testOpenBlobMediaTypes(t *testing.T, mk Maker) {
594 ctx := context.Background()
595 g, data := mk(t)
596 f := NewFixture(t, data, "media")
597 png := []byte("\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89")
598 f.WriteBytes("logo.png", png).
599 WriteBytes("noext", png).
600 Write("page.html", "<!doctype html><html><body>hi</body></html>\n").
601 Write("style.css", "a { color: red }\n").
602 Write("data.json", `{"a":1}`+"\n").
603 Write("notes", "plain words\n").
604 Write("main.go", "package main\n").
605 WriteBytes("blob.dat", []byte{0xff, 0xfe, 0, 1, 2}).
606 WriteBytes("fake.txt", []byte{'a', 0, 'b'}).
607 Write("latin", "caf\xe9\n")
608 f.Commit("media")
609
610 want := map[string]struct {
611 media string
612 binary bool
613 }{
614 "logo.png": {"image/png", true},
615 "noext": {"image/png", true},
616 "page.html": {"text/html", false},
617 "style.css": {"text/css", false},
618 "data.json": {"application/json", false},
619 "notes": {"text/plain", false},
620 "main.go": {"text/plain", false},
621 "blob.dat": {"application/octet-stream", true},
622 "fake.txt": {"application/octet-stream", true},
623 "latin": {"text/plain", false},
624 }
625 for p, w := range want {
626 info, rc, err := g.OpenBlob(ctx, f.Name, domain.Revision{}, TreePath(t, p))
627 require.NoError(t, err, p)
628 require.NoError(t, rc.Close())
629 assert.Equal(t, w.media, info.MediaType, p)
630 assert.Equal(t, w.binary, info.Binary, p)
631 }
632}
633
634func testBinaryRuleLooksAtFirst8000Bytes(t *testing.T, mk Maker) {
635 ctx := context.Background()
636 g, data := mk(t)
637 f := NewFixture(t, data, "sniff")
638 late := append([]byte(strings.Repeat("a", 8000)), 0) // NUL at offset 8000: not seen
639 early := append([]byte(strings.Repeat("a", 7999)), 0)
640 f.WriteBytes("late", late).WriteBytes("early", early)
641 // Over the in-memory threshold, so that the streaming path decides too.
642 f.WriteBytes("biglate", append([]byte(strings.Repeat("a", 300<<10)), 0)).
643 WriteBytes("bigearly", append([]byte(strings.Repeat("a", 7999)), append([]byte{0}, make([]byte, 300<<10)...)...))
644 f.Commit("sniff")
645
646 for p, binary := range map[string]bool{"late": false, "early": true, "biglate": false, "bigearly": true} {
647 info, rc, err := g.OpenBlob(ctx, f.Name, domain.Revision{}, TreePath(t, p))
648 require.NoError(t, err, p)
649 require.NoError(t, rc.Close())
650 assert.Equal(t, binary, info.Binary, p)
651 }
652}
653
654func testOpenBlobLargeThroughStream(t *testing.T, mk Maker) {
655 ctx := context.Background()
656 g, data := mk(t)
657 f := NewFixture(t, data, "large")
658 content := RandomBytes(1<<20+17, 7)
659 f.WriteBytes("big.bin", content).Commit("big")
660
661 info, rc, err := g.OpenBlob(ctx, f.Name, domain.Revision{}, TreePath(t, "big.bin"))
662 require.NoError(t, err)
663 assert.Equal(t, int64(len(content)), info.Size)
664 got, err := io.ReadAll(rc)
665 require.NoError(t, err)
666 assert.Equal(t, content, got)
667 require.NoError(t, rc.Close())
668 require.NoError(t, rc.Close(), "Close is idempotent")
669}
670
671func testCancelledContext(t *testing.T, mk Maker) {
672 g, data := mk(t)
673 h := NewHistory(t, data)
674
675 ctx, cancel := context.WithCancel(context.Background())
676 cancel()
677 _, err := g.ListRefs(ctx, h.Name)
678 assert.ErrorIs(t, err, context.Canceled)
679 _, err = g.ResolveRevision(ctx, h.Name, domain.Revision{})
680 assert.ErrorIs(t, err, context.Canceled)
681 _, err = g.GetCommit(ctx, h.Name, domain.Revision{}, domain.DefaultDiffLimits())
682 assert.ErrorIs(t, err, context.Canceled)
683 _, err = g.ListCommits(ctx, h.Name, ports.CommitQuery{Limit: 5})
684 assert.ErrorIs(t, err, context.Canceled)
685
686 // The adapter still works with a live context.
687 _, err = g.ResolveRevision(context.Background(), h.Name, domain.Revision{})
688 assert.NoError(t, err)
689}
690
691func testHostileRevisionAndPathStayLiteral(t *testing.T, mk Maker) {
692 ctx := context.Background()
693 g, data := mk(t)
694 h := NewHistory(t, data)
695
696 // Revisions that look like options or git revision syntax are rejected by
697 // the domain; ones that pass it are plain names for the reader.
698 for _, s := range []string{"--output=/tmp/x", "-h", "main^", "main~1", "main:b.txt", "@{u}", "HEAD@{1}"} {
699 _, err := domain.ParseRevision(s)
700 assert.ErrorIs(t, err, domain.ErrInvalidRevision, s)
701 }
702 _, err := g.ResolveRevision(ctx, h.Name, Rev(t, "HEAD.x"))
703 assert.ErrorIs(t, err, domain.ErrNotFound)
704 _, err = g.GetTree(ctx, h.Name, domain.Revision{}, TreePath(t, ":(top)d"), false)
705 assert.ErrorIs(t, err, domain.ErrNotFound)
706 var pe *domain.InvalidNameError
707 assert.False(t, errors.As(err, &pe))
708}
709
710// A full object id is an object id before it is a ref: a branch or a tag
711// named like a commit must not redirect a link to that commit.
712func testResolveRevisionObjectIDBeatsRefs(t *testing.T, mk Maker) {
713 ctx := context.Background()
714 g, data := mk(t)
715 h := NewHistory(t, data)
716
717 // Git itself warns about these refs and prefers the object id.
718 h.Git("-c", "advice.objectNameWarning=false", "branch", h.C1, h.C3)
719 h.Git("-c", "advice.objectNameWarning=false", "tag", h.C2, h.C3)
720
721 got, err := g.ResolveRevision(ctx, h.Name, Rev(t, h.C1))
722 require.NoError(t, err)
723 assert.Equal(t, h.C1, got.CommitID.String(), "the commit, not the branch of the same name")
724 assert.Empty(t, got.Ref)
725
726 got, err = g.ResolveRevision(ctx, h.Name, Rev(t, h.C2))
727 require.NoError(t, err)
728 assert.Equal(t, h.C2, got.CommitID.String(), "the commit, not the tag of the same name")
729
730 // An abbreviation is still a name first, as before.
731 h.Git("branch", "abc1234", h.C3)
732 if !strings.HasPrefix(h.C1, "abc1234") {
733 got, err = g.ResolveRevision(ctx, h.Name, Rev(t, "abc1234"))
734 require.NoError(t, err)
735 assert.Equal(t, h.C3, got.CommitID.String())
736 assert.Equal(t, "refs/heads/abc1234", got.Ref)
737 }
738
739 // The ref is used when no object has that id.
740 ghost := strings.Repeat("ab", 20)
741 h.Git("-c", "advice.objectNameWarning=false", "branch", ghost, h.C3)
742 got, err = g.ResolveRevision(ctx, h.Name, Rev(t, ghost))
743 require.NoError(t, err)
744 assert.Equal(t, h.C3, got.CommitID.String())
745 assert.Equal(t, "refs/heads/"+ghost, got.Ref)
746}
747
748func testGetTreeIsBounded(t *testing.T, mk Maker) {
749 ctx := context.Background()
750 g, data := mk(t)
751 f := NewFixture(t, data, "wide")
752 n := domain.MaxTreeEntries + 7
753 for i := range n {
754 f.Write(fmt.Sprintf("d/f%05d", i), "x")
755 }
756 f.Commit("wide")
757
758 tree, err := g.GetTree(ctx, f.Name, domain.Revision{}, TreePath(t, "d"), false)
759 require.NoError(t, err)
760 assert.True(t, tree.Truncated)
761 require.Len(t, tree.Entries, domain.MaxTreeEntries)
762 assert.Equal(t, "f00000", tree.Entries[0].Name, "the first entries in order")
763 assert.Equal(t, fmt.Sprintf("f%05d", domain.MaxTreeEntries-1), tree.Entries[domain.MaxTreeEntries-1].Name)
764
765 small, err := g.GetTree(ctx, f.Name, domain.Revision{}, domain.TreePath{}, false)
766 require.NoError(t, err)
767 assert.False(t, small.Truncated)
768}
769
770func testListPaths(t *testing.T, mk Maker) {
771 ctx := context.Background()
772 g, data := mk(t)
773 f := buildPaths(t, data)
774
775 // git ls-tree -r in order, minus the submodule and the path no address can
776 // carry.
777 var want []string
778 for _, rec := range strings.Split(strings.TrimSuffix(GitOut(t, f.Bare, "ls-tree", "-r", "-z", "HEAD"), "\x00"), "\x00") {
779 meta, p, _ := strings.Cut(rec, "\t")
780 if !strings.Contains(meta, " commit ") && !strings.Contains(p, "\t") {
781 want = append(want, p)
782 }
783 }
784 require.Equal(t, []string{
785 "a-b.txt", "a/b.txt", "a/b/c.txt", "d/e/f/deep.txt", "héllo wörld.txt", "link", "run.sh", "z.txt",
786 }, want, "the fixture")
787
788 got, err := g.ListPaths(ctx, f.Name, domain.Revision{}, domain.MaxPathEntries, domain.MaxPathBytes)
789 require.NoError(t, err)
790 assert.Equal(t, want, got.Paths)
791 assert.False(t, got.Truncated)
792 assert.Equal(t, GitOut(t, f.Bare, "rev-parse", "HEAD"), got.CommitID.String())
793 assert.Equal(t, GitOut(t, f.Bare, "rev-parse", "HEAD^{tree}"), got.TreeID.String())
794
795 t.Run("revisions", func(t *testing.T) {
796 for _, s := range []string{"main", "v1", "refs/tags/v1", f.Git("rev-parse", "HEAD"), f.Git("rev-parse", "HEAD")[:10]} {
797 list, err := g.ListPaths(ctx, f.Name, Rev(t, s), domain.MaxPathEntries, domain.MaxPathBytes)
798 require.NoError(t, err, s)
799 assert.Equal(t, want, list.Paths, s)
800 }
801 // The first commit has no submodule: the same files.
802 first, err := g.ListPaths(ctx, f.Name, Rev(t, f.Git("rev-parse", "HEAD~1")), domain.MaxPathEntries, domain.MaxPathBytes)
803 require.NoError(t, err)
804 assert.Equal(t, want, first.Paths)
805 assert.Equal(t, GitOut(t, f.Bare, "rev-parse", "HEAD~1^{tree}"), first.TreeID.String())
806 assert.NotEqual(t, got.TreeID, first.TreeID)
807 })
808 t.Run("not found", func(t *testing.T) {
809 for _, s := range []string{"nope", "refs/heads/nope", strings.Repeat("ab", 20), "deadbeef"} {
810 _, err := g.ListPaths(ctx, f.Name, Rev(t, s), domain.MaxPathEntries, domain.MaxPathBytes)
811 assert.ErrorIs(t, err, domain.ErrNotFound, s)
812 }
813 _, err := g.ListPaths(ctx, RepoName(t, "no-such-repo"), domain.Revision{}, domain.MaxPathEntries, domain.MaxPathBytes)
814 assert.ErrorIs(t, err, domain.ErrNotFound)
815 })
816 t.Run("empty repository", func(t *testing.T) {
817 e := NewFixture(t, data, "empty-paths")
818 _, err := g.ListPaths(ctx, e.Name, domain.Revision{}, domain.MaxPathEntries, domain.MaxPathBytes)
819 assert.ErrorIs(t, err, domain.ErrNotFound)
820 })
821 t.Run("bounds", func(t *testing.T) {
822 list, err := g.ListPaths(ctx, f.Name, domain.Revision{}, 3, domain.MaxPathBytes)
823 require.NoError(t, err)
824 assert.Equal(t, want[:3], list.Paths)
825 assert.True(t, list.Truncated)
826 assert.Equal(t, got.CommitID, list.CommitID)
827 assert.Equal(t, got.TreeID, list.TreeID)
828
829 list, err = g.ListPaths(ctx, f.Name, domain.Revision{}, len(want), domain.MaxPathBytes)
830 require.NoError(t, err)
831 assert.Equal(t, want, list.Paths)
832 assert.False(t, list.Truncated, "exactly maxPaths paths is not truncated")
833
834 sum := len(want[0]) + len(want[1]) + len(want[2])
835 list, err = g.ListPaths(ctx, f.Name, domain.Revision{}, domain.MaxPathEntries, sum-1)
836 require.NoError(t, err)
837 assert.Equal(t, want[:2], list.Paths)
838 assert.True(t, list.Truncated)
839
840 list, err = g.ListPaths(ctx, f.Name, domain.Revision{}, domain.MaxPathEntries, sum)
841 require.NoError(t, err)
842 assert.Equal(t, want[:3], list.Paths, "bytes up to maxBytes fit")
843 assert.True(t, list.Truncated)
844
845 total := 0
846 for _, p := range want {
847 total += len(p)
848 }
849 list, err = g.ListPaths(ctx, f.Name, domain.Revision{}, domain.MaxPathEntries, total)
850 require.NoError(t, err)
851 assert.Equal(t, want, list.Paths)
852 assert.False(t, list.Truncated, "exactly maxBytes bytes is not truncated")
853 })
854 t.Run("cancelled context", func(t *testing.T) {
855 ctx, cancel := context.WithCancel(ctx)
856 cancel()
857 _, err := g.ListPaths(ctx, f.Name, domain.Revision{}, domain.MaxPathEntries, domain.MaxPathBytes)
858 assert.ErrorIs(t, err, context.Canceled)
859 })
860 t.Run("a large tree", func(t *testing.T) {
861 w := NewFixture(t, data, "paths-wide")
862 for i := range 300 {
863 w.Write(fmt.Sprintf("d%02d/e%d/f%03d.txt", i%17, i%5, i), "x")
864 }
865 w.Commit("wide")
866 order := lines(GitOut(t, w.Bare, "ls-tree", "-r", "--name-only", "HEAD"))
867 list, err := g.ListPaths(ctx, w.Name, domain.Revision{}, domain.MaxPathEntries, domain.MaxPathBytes)
868 require.NoError(t, err)
869 assert.Equal(t, order, list.Paths)
870 list, err = g.ListPaths(ctx, w.Name, domain.Revision{}, 100, domain.MaxPathBytes)
871 require.NoError(t, err)
872 assert.Equal(t, order[:100], list.Paths)
873 assert.True(t, list.Truncated)
874 })
875}
876
877// A tree entry of mode 160000 (a submodule) names a commit of another
878// repository. When its id is that of a tree this repository has, it still is no
879// directory of it: both readers answer not found, and the same id as a real
880// directory lists.
881func testGetTreeRefusesAGitlinkToATree(t *testing.T, mk Maker) {
882 ctx := context.Background()
883 g, data := mk(t)
884 f := NewFixture(t, data, "gitlink")
885 f.Write("README", "r\n")
886 f.Write("dir/f.txt", "f\n")
887 f.Commit("dir")
888 dirID := f.Git("rev-parse", "HEAD:dir")
889 raw := func(entries ...[3]string) string {
890 var b strings.Builder
891 for _, e := range entries {
892 id, err := hex.DecodeString(e[2])
893 require.NoError(t, err)
894 b.WriteString(e[0] + " " + e[1] + "\x00")
895 b.Write(id)
896 }
897 return f.WriteObject("tree", b.String())
898 }
899 both := raw([3]string{"40000", "asdir", dirID}, [3]string{"160000", "sub", dirID}) // the same id, two modes
900 commit := f.Git("commit-tree", both, "-m", "gitlink")
901 f.Git("update-ref", "refs/heads/main", commit)
902
903 tree, err := g.GetTree(ctx, f.Name, Rev(t, commit), TreePath(t, "asdir"), false)
904 require.NoError(t, err)
905 require.Len(t, tree.Entries, 1)
906 assert.Equal(t, "f.txt", tree.Entries[0].Name)
907 _, err = g.GetTree(ctx, f.Name, Rev(t, commit), TreePath(t, "sub"), false)
908 assert.ErrorIs(t, err, domain.ErrNotFound, "a gitlink is no directory, whatever it names")
909}