1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package gitpush
4
5import (
6 "context"
7 "crypto/ed25519"
8 "crypto/rand"
9 "errors"
10 "os"
11 "os/exec"
12 "path/filepath"
13 "strings"
14 "testing"
15 "time"
16 "unicode/utf8"
17
18 "github.com/stretchr/testify/require"
19
20 "bnema.dev/ermite/internal/domain"
21 "bnema.dev/ermite/internal/ports"
22)
23
24func git(t *testing.T, args ...string) string {
25 t.Helper()
26 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
27 defer cancel()
28 cmd := exec.CommandContext(ctx, "git", args...)
29 cmd.Env = []string{"PATH=" + os.Getenv("PATH"), "HOME=" + t.TempDir(), "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null", "GIT_AUTHOR_NAME=Test", "GIT_AUTHOR_EMAIL=test@example.org", "GIT_COMMITTER_NAME=Test", "GIT_COMMITTER_EMAIL=test@example.org"}
30 out, err := cmd.CombinedOutput()
31 require.NoError(t, err, string(out))
32 return strings.TrimSpace(string(out))
33}
34func setup(t *testing.T) (Config, ports.MirrorPush) {
35 t.Helper()
36 root := t.TempDir()
37 source := filepath.Join(root, "source")
38 git(t, "init", "-b", "main", source)
39 git(t, "-C", source, "commit", "--allow-empty", "-m", "first")
40 bin, err := exec.LookPath("git")
41 require.NoError(t, err)
42 _, key, err := ed25519.GenerateKey(rand.Reader)
43 require.NoError(t, err)
44 return Config{Bin: bin, Path: os.Getenv("PATH"), Home: t.TempDir(), KnownHosts: filepath.Join(root, "mirror_known_hosts"), TempDir: filepath.Join(root, "keys"), RepoDir: func(domain.RepoName) string { return source }, AllowedProtocols: "file", Timeout: 5 * time.Second}, ports.MirrorPush{PrivateKey: key, Refspecs: []string{"refs/heads/main:refs/heads/main"}}
45}
46func clean(t *testing.T, cfg Config) {
47 t.Helper()
48 entries, err := os.ReadDir(cfg.TempDir)
49 require.NoError(t, err)
50 require.Empty(t, entries)
51}
52func TestPushLocal(t *testing.T) {
53 cfg, req := setup(t)
54 remote := filepath.Join(t.TempDir(), "remote.git")
55 git(t, "init", "--bare", remote)
56 req.URL = "file://" + remote
57 p := New(cfg)
58 result, err := p.Push(context.Background(), req)
59 require.NoError(t, err)
60 require.Len(t, result.Refs, 1)
61 require.Equal(t, ports.MirrorRefOK, result.Refs[0].Status)
62 clean(t, cfg)
63 result, err = p.Push(context.Background(), req)
64 require.NoError(t, err)
65 require.Equal(t, ports.MirrorRefUpToDate, result.Refs[0].Status)
66 clean(t, cfg)
67 other := filepath.Join(t.TempDir(), "other")
68 git(t, "init", "-b", "main", other)
69 git(t, "-C", other, "commit", "--allow-empty", "-m", "unrelated")
70 git(t, "-C", other, "push", "file://"+remote, "refs/heads/main:refs/heads/other")
71 // Move remote main locally, without a force push, to establish divergence.
72 otherID := git(t, "-C", other, "rev-parse", "HEAD")
73 git(t, "--git-dir", remote, "update-ref", "refs/heads/main", otherID)
74 _, err = p.Push(context.Background(), req)
75 var me *domain.MirrorError
76 require.ErrorAs(t, err, &me)
77 require.Equal(t, domain.MirrorClassDiverged, me.Class)
78 require.Equal(t, otherID, git(t, "--git-dir", remote, "rev-parse", "refs/heads/main"))
79 clean(t, cfg)
80 req.URL = "file://" + filepath.Join(t.TempDir(), "missing.git")
81 _, err = p.Push(context.Background(), req)
82 require.ErrorAs(t, err, &me)
83 require.Equal(t, domain.MirrorClassRemoteNotFound, me.Class)
84 clean(t, cfg)
85}
86func TestBoundsAndKeyEnvironment(t *testing.T) {
87 for _, mode := range []string{"success", "error", "timeout", "stdout", "stderr", "cancel"} {
88 t.Run(mode, func(t *testing.T) {
89 cfg, req := setup(t)
90 req.URL = "file:///unused"
91 script := filepath.Join(t.TempDir(), "git")
92 report := filepath.Join(t.TempDir(), "env")
93 body := "#!/bin/sh\nenv > '" + report + "'\nfor d in '" + cfg.TempDir + "'/push-*; do\n test \"$(stat -c %a \"$d\")\" = 700 || exit 90\n for k in \"$d\"/key-*; do test \"$(stat -c %a \"$k\")\" = 600 || exit 91; done\ndone\n"
94 switch mode {
95 case "error":
96 body += "echo 'Permission denied (publickey)' >&2; exit 1\n"
97 case "timeout", "cancel":
98 body += "sleep 10\n"
99 case "stdout":
100 body += "head -c 4096 /dev/zero\n"
101 case "stderr":
102 body += "head -c 4096 /dev/zero >&2\n"
103 }
104 require.NoError(t, os.WriteFile(script, []byte(body), 0700))
105 cfg.Bin = script
106 cfg.MaxOutput = 1024
107 cfg.Timeout = 5 * time.Second
108 if mode == "timeout" {
109 cfg.Timeout = 200 * time.Millisecond // the only case that must wait for the deadline
110 }
111 t.Setenv("ERMITE_PRIVATE_TEST", "not-forwarded")
112 ctx := context.Background()
113 if mode == "cancel" {
114 var cancel context.CancelFunc
115 ctx, cancel = context.WithTimeout(ctx, 50*time.Millisecond)
116 defer cancel()
117 }
118 _, err := New(cfg).Push(ctx, req)
119 switch mode {
120 case "success":
121 require.NoError(t, err)
122 case "cancel":
123 require.ErrorIs(t, err, context.DeadlineExceeded)
124 default:
125 var me *domain.MirrorError
126 require.ErrorAs(t, err, &me)
127 want := domain.MirrorClassTransient
128 if mode == "error" {
129 want = domain.MirrorClassAuthDenied
130 }
131 require.Equal(t, want, me.Class)
132 }
133 clean(t, cfg)
134 env, e := os.ReadFile(report)
135 require.NoError(t, e)
136 require.NotContains(t, string(env), "ERMITE_")
137 require.Contains(t, string(env), "IdentitiesOnly=yes")
138 require.Contains(t, string(env), "BatchMode=yes")
139 require.Contains(t, string(env), "StrictHostKeyChecking=accept-new")
140 require.Contains(t, string(env), "UserKnownHostsFile=")
141 require.Contains(t, string(env), "ConnectTimeout=30")
142 require.Contains(t, string(env), "ServerAliveInterval=15")
143 require.Contains(t, string(env), "ServerAliveCountMax=3")
144 })
145 }
146}
147func TestClassifierSamples(t *testing.T) {
148 for _, tc := range []struct {
149 s string
150 c domain.MirrorFailureClass
151 }{{"Permission denied (publickey)", domain.MirrorClassAuthDenied}, {"Repository not found", domain.MirrorClassRemoteNotFound}, {"Host key verification failed", domain.MirrorClassHostKey}, {"Could not resolve hostname", domain.MirrorClassTransient}, {"non-fast-forward", domain.MirrorClassDiverged}} {
152 require.Equal(t, tc.c, domain.ClassifyMirrorOutput(tc.s, errors.New("exit 1"), false))
153 }
154}
155func TestRejectUnsafeRefspecs(t *testing.T) {
156 for _, ref := range []string{"+refs/heads/main:refs/heads/main", ":refs/heads/main", "--mirror", "refs/heads/*:refs/heads/*", "refs/heads/main:refs/heads/other"} {
157 require.False(t, validRefspec(ref))
158 }
159 require.True(t, validRefspec("refs/tags/v1:refs/tags/v1"))
160}
161func TestSafeDetail(t *testing.T) {
162 s := safeDetail(strings.Repeat("x", 5000)+" ssh://user:password@host/repo user@host", "ssh://user:password@host/repo")
163 require.LessOrEqual(t, len(s), 4096)
164 require.NotContains(t, s, "password")
165 require.NotContains(t, s, "user@")
166}
167
168func TestSafeDetailKeepsValidUTF8(t *testing.T) {
169 // The cut falls inside a multibyte character, and the remote sent a stray byte.
170 in := "\xff" + strings.Repeat("x", 3) + strings.Repeat("é", 4097) + "\xff end"
171 var s string
172 require.NotPanics(t, func() { s = safeDetail(in, "ssh://host/repo") })
173 require.True(t, utf8.ValidString(s))
174 require.LessOrEqual(t, len(s), maxDetail)
175 require.True(t, strings.HasSuffix(s, "? end"))
176}
177
178func TestClassifyIgnoresRemoteLines(t *testing.T) {
179 stderr := "remote: Permission denied (publickey) is what your CI says\nfatal: unable to access: Could not resolve hostname\n"
180 require.Equal(t, domain.MirrorClassTransient, domain.ClassifyMirrorOutput(withoutRemoteLines(stderr), errors.New("exit 128"), false))
181 require.Contains(t, withoutRemoteLines("remote: a\n remote: b\nkept"), "kept")
182 require.NotContains(t, withoutRemoteLines("remote: a\n remote: b\nkept"), "remote:")
183}
184
185func TestRemoteLinesStayInDetailButNotInClass(t *testing.T) {
186 cfg, req := setup(t)
187 req.URL = "file:///unused"
188 script := filepath.Join(t.TempDir(), "git")
189 body := "#!/bin/sh\necho 'remote: error: non-fast-forward hook says no' >&2\necho 'fatal: Could not read from remote repository.' >&2\nexit 1\n"
190 require.NoError(t, os.WriteFile(script, []byte(body), 0700))
191 cfg.Bin = script
192 _, err := New(cfg).Push(context.Background(), req)
193 var me *domain.MirrorError
194 require.ErrorAs(t, err, &me)
195 require.Equal(t, domain.MirrorClassTransient, me.Class, "a message from the remote does not pick the class")
196 require.Contains(t, me.Detail, "remote: error")
197}