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