| @@ -0,0 +1,197 @@ |
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later |
| 2 | + | |
| 3 | + | package gitpush |
| 4 | + | |
| 5 | + | import ( |
| 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 | + | |
| 24 | + | func 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 | + | } |
| 34 | + | func 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 | + | } |
| 46 | + | func 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 | + | } |
| 52 | + | func 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 | + | } |
| 86 | + | func 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 | + | } |
| 147 | + | func 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 | + | } |
| 155 | + | func 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 | + | } |
| 161 | + | func 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 | + | |
| 168 | + | func 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 | + | |
| 178 | + | func 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 | + | |
| 185 | + | func 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 | + | } |