v0.27.0 0039612

signatures_test.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package usecase_test
4
5import (
6	"context"
7	"errors"
8	"fmt"
9	"strings"
10	"testing"
11	"time"
12
13	"github.com/stretchr/testify/assert"
14	"github.com/stretchr/testify/mock"
15	"github.com/stretchr/testify/require"
16
17	"bnema.dev/ermite/internal/domain"
18	"bnema.dev/ermite/internal/ports"
19	"bnema.dev/ermite/internal/ports/mocks"
20	"bnema.dev/ermite/internal/usecase"
21)
22
23const (
24	sshArmor  = "-----BEGIN SSH SIGNATURE-----\n AAAA\n -----END SSH SIGNATURE-----\n"
25	gpgArmor  = "-----BEGIN PGP SIGNATURE-----\n\n iQ==\n -----END PGP SIGNATURE-----\n"
26	x509Armor = "-----BEGIN SIGNED MESSAGE-----\n MIIB\n -----END SIGNED MESSAGE-----\n"
27)
28
29var sigNow = time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC)
30
31type sigFixture struct {
32	store     *mocks.TrustedKeyStore
33	inspector *mocks.KeyInspector
34	ssh, gpg  *mocks.SignatureVerifier
35	objects   *mocks.CommitObjectReader
36	clock     *mocks.Clock
37	sigs      *usecase.Signatures
38}
39
40var builtinKey = domain.TrustedKey{
41	ID: "SHA256:builtin", Format: domain.FormatSSH, Title: "instance", Emails: []string{"bot@example.org"}, Builtin: true,
42}
43
44func newSigFixture(t *testing.T, stored ...domain.TrustedKey) *sigFixture {
45	t.Helper()
46	f := &sigFixture{
47		store: mocks.NewTrustedKeyStore(t), inspector: mocks.NewKeyInspector(t),
48		ssh: mocks.NewSignatureVerifier(t), gpg: mocks.NewSignatureVerifier(t),
49		objects: mocks.NewCommitObjectReader(t), clock: mocks.NewClock(t),
50	}
51	f.clock.EXPECT().Now().Return(sigNow).Maybe()
52	f.store.EXPECT().ListTrustedKeys(mock.Anything).Return(stored, nil)
53	var err error
54	f.sigs, err = usecase.NewSignatures(f.store, f.inspector, map[domain.SignatureFormat]ports.SignatureVerifier{
55		domain.FormatSSH: f.ssh, domain.FormatGPG: f.gpg,
56	}, f.objects, builtinKey, f.clock)
57	require.NoError(t, err)
58	return f
59}
60
61func sigToken(scopes []domain.Scope, repos ...domain.RepoName) domain.Principal {
62	return domain.TokenPrincipal(domain.Token{ID: "t", Scopes: scopes, Repos: repos})
63}
64
65func TestNewSignaturesLoadError(t *testing.T) {
66	t.Parallel()
67	store := mocks.NewTrustedKeyStore(t)
68	store.EXPECT().ListTrustedKeys(mock.Anything).Return(nil, errors.New("boom"))
69	_, err := usecase.NewSignatures(store, nil, nil, nil, domain.TrustedKey{}, nil)
70	require.Error(t, err)
71}
72
73func TestTrustedKeyAccess(t *testing.T) {
74	t.Parallel()
75	ctx := context.Background()
76	reader := sigToken([]domain.Scope{domain.ScopeRepoRead})
77	owner := sigToken(domain.AllScopes())
78
79	t.Run("list", func(t *testing.T) {
80		t.Parallel()
81		f := newSigFixture(t)
82		_, err := f.sigs.ListTrustedKeys(ctx, domain.Anonymous())
83		assert.ErrorIs(t, err, domain.ErrUnauthenticated)
84		for _, p := range []domain.Principal{reader, owner, domain.Root(), domain.Owner()} {
85			_, err := f.sigs.ListTrustedKeys(ctx, p)
86			assert.NoError(t, err)
87		}
88	})
89
90	t.Run("add and remove", func(t *testing.T) {
91		t.Parallel()
92		f := newSigFixture(t)
93		_, err := f.sigs.AddTrustedKey(ctx, domain.Anonymous(), domain.FormatSSH, "t", "k", []string{"a@b.c"})
94		assert.ErrorIs(t, err, domain.ErrUnauthenticated)
95		err = f.sigs.RemoveTrustedKey(ctx, domain.Anonymous(), "x")
96		assert.ErrorIs(t, err, domain.ErrUnauthenticated)
97
98		_, err = f.sigs.AddTrustedKey(ctx, reader, domain.FormatSSH, "t", "k", []string{"a@b.c"})
99		assert.ErrorIs(t, err, domain.ErrForbidden)
100		assert.Contains(t, err.Error(), "managing trusted keys needs")
101		err = f.sigs.RemoveTrustedKey(ctx, reader, "x")
102		assert.ErrorIs(t, err, domain.ErrForbidden)
103
104		limited := sigToken(domain.AllScopes(), name(t, "r"))
105		_, err = f.sigs.AddTrustedKey(ctx, limited, domain.FormatSSH, "t", "k", []string{"a@b.c"})
106		assert.ErrorIs(t, err, domain.ErrForbidden)
107
108		for _, p := range []domain.Principal{owner, domain.Root(), domain.Owner()} {
109			id := "SHA256:" + p.Label()
110			f.inspector.EXPECT().Inspect(domain.FormatSSH, "k").Return(id, "ssh-ed25519 K", nil).Once()
111			f.store.EXPECT().CreateTrustedKey(ctx, mock.Anything).Return(nil).Once()
112			_, err := f.sigs.AddTrustedKey(ctx, p, domain.FormatSSH, "t", "k", []string{"a@b.c"})
113			assert.NoError(t, err)
114			f.store.EXPECT().DeleteTrustedKey(ctx, id).Return(nil).Once()
115			assert.NoError(t, f.sigs.RemoveTrustedKey(ctx, p, id))
116		}
117	})
118}
119
120func TestAddTrustedKey(t *testing.T) {
121	t.Parallel()
122	ctx := context.Background()
123	owner := domain.Root()
124
125	t.Run("stores the canonical key and lists it after the builtin", func(t *testing.T) {
126		t.Parallel()
127		f := newSigFixture(t)
128		f.inspector.EXPECT().Inspect(domain.FormatSSH, "ssh-ed25519 K comment").Return("SHA256:k1", "ssh-ed25519 K", nil)
129		want := domain.TrustedKey{
130			ID: "SHA256:k1", Format: domain.FormatSSH, Title: "laptop", PublicKey: "ssh-ed25519 K",
131			Emails: []string{"a@b.c", "d@e.f"}, CreatedAt: sigNow,
132		}
133		f.store.EXPECT().CreateTrustedKey(ctx, want).Return(nil)
134		got, err := f.sigs.AddTrustedKey(ctx, owner, domain.FormatSSH, "  laptop ", "ssh-ed25519 K comment", []string{"A@B.c", " d@e.f", "a@b.c"})
135		require.NoError(t, err)
136		assert.Equal(t, want, got)
137
138		keys, err := f.sigs.ListTrustedKeys(ctx, owner)
139		require.NoError(t, err)
140		require.Len(t, keys, 2)
141		assert.Equal(t, builtinKey, keys[0])
142		assert.Equal(t, want, keys[1])
143	})
144
145	t.Run("validation", func(t *testing.T) {
146		t.Parallel()
147		f := newSigFixture(t)
148		for name, call := range map[string]func() error{
149			"empty title": func() error {
150				_, err := f.sigs.AddTrustedKey(ctx, owner, domain.FormatSSH, " ", "k", []string{"a@b.c"})
151				return err
152			},
153			"no email": func() error {
154				_, err := f.sigs.AddTrustedKey(ctx, owner, domain.FormatSSH, "t", "k", nil)
155				return err
156			},
157			"bad email": func() error {
158				_, err := f.sigs.AddTrustedKey(ctx, owner, domain.FormatSSH, "t", "k", []string{"not an email"})
159				return err
160			},
161			"x509": func() error {
162				_, err := f.sigs.AddTrustedKey(ctx, owner, domain.FormatX509, "t", "k", []string{"a@b.c"})
163				return err
164			},
165			"no format": func() error {
166				_, err := f.sigs.AddTrustedKey(ctx, owner, domain.FormatNone, "t", "k", []string{"a@b.c"})
167				return err
168			},
169			"key too big": func() error {
170				_, err := f.sigs.AddTrustedKey(ctx, owner, domain.FormatGPG, "t", strings.Repeat("x", domain.MaxTrustedKeyBytes+1), []string{"a@b.c"})
171				return err
172			},
173		} {
174			assert.ErrorIs(t, call(), domain.ErrInvalid, name)
175		}
176	})
177
178	t.Run("inspector error is returned", func(t *testing.T) {
179		t.Parallel()
180		f := newSigFixture(t)
181		f.inspector.EXPECT().Inspect(domain.FormatGPG, "bad").Return("", "", fmt.Errorf("%w: nope", domain.ErrInvalid))
182		_, err := f.sigs.AddTrustedKey(ctx, owner, domain.FormatGPG, "t", "bad", []string{"a@b.c"})
183		assert.ErrorIs(t, err, domain.ErrInvalid)
184	})
185
186	t.Run("builtin and duplicates are refused", func(t *testing.T) {
187		t.Parallel()
188		f := newSigFixture(t, domain.TrustedKey{ID: "SHA256:old", Format: domain.FormatSSH})
189		f.inspector.EXPECT().Inspect(domain.FormatSSH, "b").Return(builtinKey.ID, "ssh-ed25519 B", nil)
190		_, err := f.sigs.AddTrustedKey(ctx, owner, domain.FormatSSH, "t", "b", []string{"a@b.c"})
191		assert.ErrorIs(t, err, domain.ErrAlreadyExists)
192
193		f.inspector.EXPECT().Inspect(domain.FormatSSH, "o").Return("SHA256:old", "ssh-ed25519 O", nil)
194		_, err = f.sigs.AddTrustedKey(ctx, owner, domain.FormatSSH, "t", "o", []string{"a@b.c"})
195		assert.ErrorIs(t, err, domain.ErrAlreadyExists)
196	})
197
198	t.Run("limit", func(t *testing.T) {
199		t.Parallel()
200		var stored []domain.TrustedKey
201		for i := range domain.MaxTrustedKeys {
202			stored = append(stored, domain.TrustedKey{ID: fmt.Sprintf("SHA256:%d", i), Format: domain.FormatSSH})
203		}
204		f := newSigFixture(t, stored...)
205		f.inspector.EXPECT().Inspect(domain.FormatSSH, "n").Return("SHA256:new", "ssh-ed25519 N", nil)
206		_, err := f.sigs.AddTrustedKey(ctx, owner, domain.FormatSSH, "t", "n", []string{"a@b.c"})
207		assert.ErrorIs(t, err, domain.ErrResourceExhausted)
208	})
209
210	t.Run("store error leaves the list unchanged", func(t *testing.T) {
211		t.Parallel()
212		f := newSigFixture(t)
213		f.inspector.EXPECT().Inspect(domain.FormatSSH, "k").Return("SHA256:k", "ssh-ed25519 K", nil)
214		f.store.EXPECT().CreateTrustedKey(ctx, mock.Anything).Return(errors.New("down"))
215		_, err := f.sigs.AddTrustedKey(ctx, owner, domain.FormatSSH, "t", "k", []string{"a@b.c"})
216		require.Error(t, err)
217		keys, _ := f.sigs.ListTrustedKeys(ctx, owner)
218		assert.Len(t, keys, 1)
219	})
220}
221
222func TestRemoveTrustedKey(t *testing.T) {
223	t.Parallel()
224	ctx := context.Background()
225
226	t.Run("builtin is refused without touching the store", func(t *testing.T) {
227		t.Parallel()
228		f := newSigFixture(t)
229		err := f.sigs.RemoveTrustedKey(ctx, domain.Root(), builtinKey.ID)
230		assert.ErrorIs(t, err, domain.ErrPreconditionFailed)
231	})
232
233	t.Run("removes from the store and the list", func(t *testing.T) {
234		t.Parallel()
235		f := newSigFixture(t, domain.TrustedKey{ID: "SHA256:a"}, domain.TrustedKey{ID: "SHA256:b"})
236		f.store.EXPECT().DeleteTrustedKey(ctx, "SHA256:a").Return(nil)
237		require.NoError(t, f.sigs.RemoveTrustedKey(ctx, domain.Root(), "SHA256:a"))
238		keys, _ := f.sigs.ListTrustedKeys(ctx, domain.Root())
239		require.Len(t, keys, 2)
240		assert.Equal(t, "SHA256:b", keys[1].ID)
241	})
242
243	t.Run("store errors are returned and the key stays", func(t *testing.T) {
244		t.Parallel()
245		f := newSigFixture(t, domain.TrustedKey{ID: "SHA256:a"})
246		f.store.EXPECT().DeleteTrustedKey(ctx, "SHA256:a").Return(domain.ErrNotFound)
247		assert.ErrorIs(t, f.sigs.RemoveTrustedKey(ctx, domain.Root(), "SHA256:a"), domain.ErrNotFound)
248		keys, _ := f.sigs.ListTrustedKeys(ctx, domain.Root())
249		assert.Len(t, keys, 2)
250	})
251}
252
253func TestListWithoutBuiltin(t *testing.T) {
254	t.Parallel()
255	store := mocks.NewTrustedKeyStore(t)
256	store.EXPECT().ListTrustedKeys(mock.Anything).Return([]domain.TrustedKey{{ID: "SHA256:a"}}, nil)
257	s, err := usecase.NewSignatures(store, nil, nil, nil, domain.TrustedKey{}, nil)
258	require.NoError(t, err)
259	keys, err := s.ListTrustedKeys(context.Background(), domain.Root())
260	require.NoError(t, err)
261	require.Len(t, keys, 1)
262	assert.Equal(t, "SHA256:a", keys[0].ID)
263}
264
265// rawCommit builds a commit object with the signature armor in a gpgsig header.
266func rawCommit(armor string) []byte {
267	var b strings.Builder
268	b.WriteString("tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\nauthor A <a@b.c> 1 +0000\ncommitter A <a@b.c> 1 +0000\n")
269	if armor != "" {
270		lines := strings.Split(strings.TrimSuffix(armor, "\n"), "\n")
271		b.WriteString("gpgsig " + strings.TrimPrefix(lines[0], " ") + "\n")
272		for _, l := range lines[1:] {
273			b.WriteString(" " + strings.TrimPrefix(l, " ") + "\n")
274		}
275	}
276	b.WriteString("\nmessage\n")
277	return []byte(b.String())
278}
279
280func sigCommit(c string) domain.Commit {
281	return domain.Commit{ID: oid(c), Committer: domain.Signature{Email: "a@b.c", Time: time.Unix(1700000000, 0)}}
282}
283
284func TestVerifyCommits(t *testing.T) {
285	t.Parallel()
286	ctx := context.Background()
287	repo := name(t, "r")
288	stored := []domain.TrustedKey{
289		{ID: "SHA256:s1", Format: domain.FormatSSH, Emails: []string{"a@b.c"}},
290		{ID: "ABCD", Format: domain.FormatGPG, Emails: []string{"a@b.c"}},
291	}
292
293	t.Run("unsigned, x509 and unknown armor need no verifier", func(t *testing.T) {
294		t.Parallel()
295		f := newSigFixture(t, stored...)
296		cs := []domain.Commit{sigCommit("1"), sigCommit("2"), sigCommit("3")}
297		f.objects.EXPECT().ReadCommitObjects(ctx, repo, []domain.ObjectID{oid("1"), oid("2"), oid("3")}).
298			Return(map[domain.ObjectID][]byte{
299				oid("1"): rawCommit(""),
300				oid("2"): rawCommit(x509Armor),
301				oid("3"): rawCommit("-----BEGIN WHAT-----\n x\n -----END WHAT-----\n"),
302			}, nil).Once()
303		got := f.sigs.VerifyCommits(ctx, repo, cs)
304		assert.Equal(t, map[domain.ObjectID]domain.CommitVerification{
305			oid("1"): {Status: domain.StatusUnsigned},
306			oid("2"): {Format: domain.FormatX509, Status: domain.StatusUnsupported},
307			oid("3"): {Format: domain.FormatNone, Status: domain.StatusBadSignature},
308		}, got)
309	})
310
311	t.Run("ssh gets the builtin and the ssh keys only", func(t *testing.T) {
312		t.Parallel()
313		f := newSigFixture(t, stored...)
314		c := sigCommit("1")
315		raw := rawCommit(sshArmor)
316		f.objects.EXPECT().ReadCommitObjects(ctx, repo, []domain.ObjectID{c.ID}).Return(map[domain.ObjectID][]byte{c.ID: raw}, nil)
317		want := domain.CommitVerification{Format: domain.FormatSSH, Status: domain.StatusVerified, Fingerprint: "SHA256:s1", KeyID: "SHA256:s1"}
318		payload, sig, ok := domain.SplitCommitSignature(raw)
319		require.True(t, ok)
320		f.ssh.EXPECT().Verify(payload, sig, "a@b.c", int64(1700000000), []domain.TrustedKey{builtinKey, stored[0]}).Return(want)
321		assert.Equal(t, want, f.sigs.VerifyCommits(ctx, repo, []domain.Commit{c})[c.ID])
322	})
323
324	t.Run("gpg gets the gpg keys only", func(t *testing.T) {
325		t.Parallel()
326		f := newSigFixture(t, stored...)
327		c := sigCommit("1")
328		raw := rawCommit(gpgArmor)
329		f.objects.EXPECT().ReadCommitObjects(ctx, repo, []domain.ObjectID{c.ID}).Return(map[domain.ObjectID][]byte{c.ID: raw}, nil)
330		want := domain.CommitVerification{Format: domain.FormatGPG, Status: domain.StatusExpired}
331		payload, sig, _ := domain.SplitCommitSignature(raw)
332		f.gpg.EXPECT().Verify(payload, sig, "a@b.c", int64(1700000000), []domain.TrustedKey{stored[1]}).Return(want)
333		assert.Equal(t, want, f.sigs.VerifyCommits(ctx, repo, []domain.Commit{c})[c.ID])
334	})
335
336	t.Run("results are cached per repository", func(t *testing.T) {
337		t.Parallel()
338		f := newSigFixture(t, stored...)
339		c := sigCommit("1")
340		f.objects.EXPECT().ReadCommitObjects(ctx, repo, []domain.ObjectID{c.ID}).
341			Return(map[domain.ObjectID][]byte{c.ID: rawCommit("")}, nil).Once()
342		first := f.sigs.VerifyCommits(ctx, repo, []domain.Commit{c})
343		second := f.sigs.VerifyCommits(ctx, repo, []domain.Commit{c})
344		assert.Equal(t, first, second)
345
346		// Another repository is another entry.
347		other := name(t, "other")
348		f.objects.EXPECT().ReadCommitObjects(ctx, other, []domain.ObjectID{c.ID}).
349			Return(map[domain.ObjectID][]byte{c.ID: rawCommit("")}, nil).Once()
350		f.sigs.VerifyCommits(ctx, other, []domain.Commit{c})
351	})
352
353	t.Run("only the misses are read", func(t *testing.T) {
354		t.Parallel()
355		f := newSigFixture(t, stored...)
356		f.objects.EXPECT().ReadCommitObjects(ctx, repo, []domain.ObjectID{oid("1")}).
357			Return(map[domain.ObjectID][]byte{oid("1"): rawCommit("")}, nil).Once()
358		f.sigs.VerifyCommits(ctx, repo, []domain.Commit{sigCommit("1")})
359		f.objects.EXPECT().ReadCommitObjects(ctx, repo, []domain.ObjectID{oid("2")}).
360			Return(map[domain.ObjectID][]byte{oid("2"): rawCommit("")}, nil).Once()
361		got := f.sigs.VerifyCommits(ctx, repo, []domain.Commit{sigCommit("1"), sigCommit("2")})
362		assert.Len(t, got, 2)
363	})
364
365	t.Run("adding or removing a key invalidates the cache", func(t *testing.T) {
366		t.Parallel()
367		f := newSigFixture(t, stored...)
368		c := sigCommit("1")
369		f.objects.EXPECT().ReadCommitObjects(ctx, repo, []domain.ObjectID{c.ID}).
370			Return(map[domain.ObjectID][]byte{c.ID: rawCommit("")}, nil).Times(3)
371		f.sigs.VerifyCommits(ctx, repo, []domain.Commit{c})
372
373		f.inspector.EXPECT().Inspect(domain.FormatSSH, "k").Return("SHA256:new", "ssh-ed25519 N", nil)
374		f.store.EXPECT().CreateTrustedKey(ctx, mock.Anything).Return(nil)
375		_, err := f.sigs.AddTrustedKey(ctx, domain.Root(), domain.FormatSSH, "t", "k", []string{"a@b.c"})
376		require.NoError(t, err)
377		f.sigs.VerifyCommits(ctx, repo, []domain.Commit{c}) // read 2
378		f.sigs.VerifyCommits(ctx, repo, []domain.Commit{c}) // cached
379
380		f.store.EXPECT().DeleteTrustedKey(ctx, "SHA256:new").Return(nil)
381		require.NoError(t, f.sigs.RemoveTrustedKey(ctx, domain.Root(), "SHA256:new"))
382		f.sigs.VerifyCommits(ctx, repo, []domain.Commit{c}) // read 3
383	})
384
385	t.Run("a read error returns the cached results only", func(t *testing.T) {
386		t.Parallel()
387		f := newSigFixture(t, stored...)
388		f.objects.EXPECT().ReadCommitObjects(ctx, repo, []domain.ObjectID{oid("1")}).
389			Return(map[domain.ObjectID][]byte{oid("1"): rawCommit("")}, nil).Once()
390		f.sigs.VerifyCommits(ctx, repo, []domain.Commit{sigCommit("1")})
391
392		f.objects.EXPECT().ReadCommitObjects(ctx, repo, []domain.ObjectID{oid("2")}).Return(nil, errors.New("disk")).Once()
393		got := f.sigs.VerifyCommits(ctx, repo, []domain.Commit{sigCommit("1"), sigCommit("2")})
394		assert.Equal(t, map[domain.ObjectID]domain.CommitVerification{oid("1"): {Status: domain.StatusUnsigned}}, got)
395
396		// A failure is not cached.
397		f.objects.EXPECT().ReadCommitObjects(ctx, repo, []domain.ObjectID{oid("2")}).
398			Return(map[domain.ObjectID][]byte{oid("2"): rawCommit("")}, nil).Once()
399		assert.Len(t, f.sigs.VerifyCommits(ctx, repo, []domain.Commit{sigCommit("2")}), 1)
400	})
401
402	t.Run("a commit missing from the raw map is left out and not cached", func(t *testing.T) {
403		t.Parallel()
404		f := newSigFixture(t, stored...)
405		f.objects.EXPECT().ReadCommitObjects(ctx, repo, []domain.ObjectID{oid("1")}).Return(map[domain.ObjectID][]byte{}, nil).Twice()
406		assert.Empty(t, f.sigs.VerifyCommits(ctx, repo, []domain.Commit{sigCommit("1")}))
407		assert.Empty(t, f.sigs.VerifyCommits(ctx, repo, []domain.Commit{sigCommit("1")}))
408	})
409
410	t.Run("no commits, no read", func(t *testing.T) {
411		t.Parallel()
412		f := newSigFixture(t)
413		assert.Empty(t, f.sigs.VerifyCommits(ctx, repo, nil))
414	})
415
416	t.Run("the cache stays bounded", func(t *testing.T) {
417		t.Parallel()
418		f := newSigFixture(t)
419		read := 0 // ids asked to the reader
420		f.objects.EXPECT().ReadCommitObjects(ctx, repo, mock.Anything).
421			RunAndReturn(func(_ context.Context, _ domain.RepoName, ids []domain.ObjectID) (map[domain.ObjectID][]byte, error) {
422				read += len(ids)
423				m := make(map[domain.ObjectID][]byte, len(ids))
424				for _, id := range ids {
425					m[id] = rawCommit("")
426				}
427				return m, nil
428			})
429		mk := func(from, n int) []domain.Commit {
430			cs := make([]domain.Commit, n)
431			for i := range cs {
432				cs[i] = sigCommit("a")
433				cs[i].ID = numID(from + i)
434			}
435			return cs
436		}
437		const total = 2 * usecase.MaxSignatureCacheEntries
438		for from := 0; from < total; from += 1000 {
439			assert.Len(t, f.sigs.VerifyCommits(ctx, repo, mk(from, 1000)), 1000)
440		}
441		assert.Equal(t, total, read)
442		// The latest page is still cached.
443		f.sigs.VerifyCommits(ctx, repo, mk(total-1000, 1000))
444		assert.Equal(t, total, read)
445		// At most MaxSignatureCacheEntries of the total are kept.
446		read = 0
447		assert.Len(t, f.sigs.VerifyCommits(ctx, repo, mk(0, total)), total)
448		assert.GreaterOrEqual(t, read, total-usecase.MaxSignatureCacheEntries)
449	})
450}
451
452func numID(n int) domain.ObjectID {
453	id, err := domain.ParseObjectID(fmt.Sprintf("%040x", n+1))
454	if err != nil {
455		panic(err)
456	}
457	return id
458}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 44

↑↓ moveenter openesc close