detached 98d36bf

helpers_test.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package sigverify_test
4
5import (
6	"bytes"
7	"os"
8	"os/exec"
9	"path/filepath"
10	"strconv"
11	"strings"
12	"testing"
13	"time"
14
15	"github.com/stretchr/testify/require"
16
17	"bnema.dev/ermite/internal/domain"
18)
19
20// need skips the test when a tool is missing, except in CI where it must exist.
21func need(t *testing.T, tool string) string {
22	t.Helper()
23	path, err := exec.LookPath(tool)
24	if err != nil {
25		if os.Getenv("CI") != "" {
26			t.Fatalf("%s not found: CI must have it", tool)
27		}
28		t.Skipf("%s not found: install it to check signatures against real tools", tool)
29	}
30	return path
31}
32
33// run runs a command and returns its stdout; a failure fails the test with
34// its output.
35func run(t *testing.T, dir string, env []string, stdin string, name string, args ...string) string {
36	t.Helper()
37	cmd := exec.Command(name, args...)
38	cmd.Dir = dir
39	cmd.Env = append(os.Environ(), env...)
40	cmd.Stdin = strings.NewReader(stdin)
41	var stdout, stderr bytes.Buffer
42	cmd.Stdout, cmd.Stderr = &stdout, &stderr
43	require.NoError(t, cmd.Run(), "%s %v: %s", name, args, stderr.String())
44	return stdout.String()
45}
46
47// signedCommit is a commit as the verifier sees it.
48type signedCommit struct {
49	payload, signature []byte
50	signedAt           int64
51	email              string
52}
53
54// newRepo makes an empty Git repository with a committer identity.
55func newRepo(t *testing.T) string {
56	t.Helper()
57	need(t, "git")
58	dir := t.TempDir()
59	run(t, dir, nil, "", "git", "init", "-q")
60	return dir
61}
62
63// commit makes a signed empty commit in dir, with extra git config, and
64// returns it split the way ermite reads it.
65func commit(t *testing.T, dir, email string, env []string, config ...string) signedCommit {
66	t.Helper()
67	now := time.Now().Unix()
68	args := []string{"-c", "user.name=Test", "-c", "user.email=" + email}
69	for _, c := range config {
70		args = append(args, "-c", c)
71	}
72	args = append(args, "commit", "-q", "--allow-empty", "-S", "-m", "signed commit")
73	env = append(env,
74		"GIT_COMMITTER_DATE="+strconv.FormatInt(now, 10)+" +0000",
75		"GIT_AUTHOR_DATE="+strconv.FormatInt(now, 10)+" +0000",
76	)
77	run(t, dir, env, "", "git", args...)
78	raw := run(t, dir, nil, "", "git", "cat-file", "commit", "HEAD")
79	payload, signature, ok := domain.SplitCommitSignature([]byte(raw))
80	require.True(t, ok, "the commit must be signed:\n%s", raw)
81	return signedCommit{payload: payload, signature: signature, signedAt: now, email: email}
82}
83
84// sshKey makes an SSH key pair and returns the private key path and the
85// public key line.
86func sshKey(t *testing.T, typ string, extra ...string) (private, public string) {
87	t.Helper()
88	need(t, "ssh-keygen")
89	private = filepath.Join(t.TempDir(), "key")
90	args := append([]string{"-q", "-t", typ, "-N", "", "-C", "test comment", "-f", private}, extra...)
91	run(t, "", nil, "", "ssh-keygen", args...)
92	pub, err := os.ReadFile(private + ".pub")
93	require.NoError(t, err)
94	return private, strings.TrimSpace(string(pub))
95}
96
97// sshCommit makes a commit signed by an SSH private key.
98func sshCommit(t *testing.T, private, email string) signedCommit {
99	t.Helper()
100	return commit(t, newRepo(t), email, nil, "gpg.format=ssh", "user.signingkey="+private)
101}
102
103// gpgHome is a throw-away GnuPG home directory. It lives in /tmp: the agent
104// socket path is limited to 108 bytes.
105type gpgHome struct {
106	t    *testing.T
107	home string
108}
109
110func newGPGHome(t *testing.T) gpgHome {
111	t.Helper()
112	need(t, "gpg")
113	home, err := os.MkdirTemp("", "sigverify-gpg")
114	require.NoError(t, err)
115	require.NoError(t, os.Chmod(home, 0o700))
116	t.Cleanup(func() {
117		_ = exec.Command("gpgconf", "--homedir", home, "--kill", "all").Run()
118		_ = os.RemoveAll(home)
119	})
120	return gpgHome{t: t, home: home}
121}
122
123func (g gpgHome) gpg(stdin string, args ...string) string {
124	g.t.Helper()
125	return run(g.t, "", nil, stdin, "gpg", append([]string{"--homedir", g.home, "--batch", "--yes", "--no-tty"}, args...)...)
126}
127
128// generate makes a key without passphrase and returns its fingerprint. expire
129// is a gpg expiration like "never" or "2d".
130func (g gpgHome) generate(uid, algo, expire string) string {
131	g.t.Helper()
132	g.gpg("", "--passphrase", "", "--quick-gen-key", uid, algo, "sign", expire)
133	return g.fingerprint(uid)
134}
135
136// fingerprint returns the primary key fingerprint of the key of uid.
137func (g gpgHome) fingerprint(uid string) string {
138	g.t.Helper()
139	out := g.gpg("", "--with-colons", "--fingerprint", "--list-keys", uid)
140	for _, l := range strings.Split(out, "\n") {
141		if strings.HasPrefix(l, "fpr:") {
142			return strings.Split(l, ":")[9]
143		}
144	}
145	g.t.Fatalf("no fingerprint in %q", out)
146	return ""
147}
148
149// public exports the public keys, in one armor block.
150func (g gpgHome) public(fpr ...string) string {
151	g.t.Helper()
152	return g.gpg("", append([]string{"--armor", "--export"}, fpr...)...)
153}
154
155func (g gpgHome) private(fpr string) string {
156	g.t.Helper()
157	return g.gpg("", "--armor", "--export-secret-keys", fpr)
158}
159
160// revoke publishes a revocation certificate of the key in the keyring.
161func (g gpgHome) revoke(fpr string) {
162	g.t.Helper()
163	cert := filepath.Join(g.home, "revoke.asc")
164	// --gen-revoke asks questions: it cannot run in batch mode.
165	run(g.t, "", nil, "y\n0\n\ny\n", "gpg", "--homedir", g.home, "--no-tty", "--yes", "--command-fd", "0", "--output", cert, "--gen-revoke", fpr)
166	g.gpg("", "--import", cert)
167}
168
169// detachSign signs data with the key (armored detached signature). extra are
170// gpg options such as "--digest-algo", "SHA1" or "--faked-system-time", "<t>!".
171func (g gpgHome) detachSign(fpr string, data []byte, extra ...string) []byte {
172	g.t.Helper()
173	args := append(append([]string{}, extra...), "--armor", "--detach-sign", "-u", fpr)
174	return []byte(run(g.t, "", nil, string(data), "gpg", append([]string{"--homedir", g.home, "--batch", "--yes", "--no-tty"}, args...)...))
175}
176
177// fakedTime formats a time for gpg's --faked-system-time.
178func fakedTime(t time.Time) string { return t.UTC().Format("20060102T150405") + "!" }
179
180// commit makes a commit signed by the key.
181func (g gpgHome) commit(fpr, email string) signedCommit {
182	g.t.Helper()
183	return commit(g.t, newRepo(g.t), email, []string{"GNUPGHOME=" + g.home}, "gpg.format=openpgp", "user.signingkey="+fpr)
184}
185
186func trustedSSH(public string, emails ...string) domain.TrustedKey {
187	return domain.TrustedKey{ID: "ssh-id", Format: domain.FormatSSH, Title: "ssh key", PublicKey: public, Emails: emails}
188}
189
190func trustedGPG(public string, emails ...string) domain.TrustedKey {
191	return domain.TrustedKey{ID: "gpg-id", Format: domain.FormatGPG, Title: "gpg key", PublicKey: public, Emails: emails}
192}

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 4

Tags 45

↑↓ moveenter openesc close