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