Add git credential helper fallback · Entire
Add git credential helper fallback
c487e35→
Sessions
939c30174771View transcript
Changes
5
MREADME.md+9
internal/syncer
Aauth_test.go+41
Mintegration_test.go+67
Mprotocol_v2.go+2/-2
Msyncer.go+100/-3
157 unmodified lines
158
159
160
161
162
163
164
165
166
167
168
169
170
4 unmodified lines
175
176
177
178
179
180
181
182
157 unmodified lines
For GitHub and similar providers, use basic auth with a token as the password.
Auth is resolved in this order:
- explicit CLI flags
- `GITSYNC_*` environment variables
- local `git credential fill` helper lookup for `http` and `https` remotes
- anonymous access
- `GITSYNC_SOURCE_TOKEN`
- `GITSYNC_TARGET_TOKEN`
- `GITSYNC_SOURCE_USERNAME` default: `git`
4 unmodified lines
- `GITSYNC_SOURCE_BEARER_TOKEN`
- `GITSYNC_TARGET_BEARER_TOKEN`
That means local testing against a dummy GitHub repo can reuse your regular Git credential helper setup without passing tokens on every command.
## Behavior
- Source refs are listed with `GET /info/refs?service=git-upload-pack`.
MREADME.md+9
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
package syncer
import (
"context"
"testing"
"github.com/go-git/go-git/v5/plumbing/transport"
transporthttp "github.com/go-git/go-git/v5/plumbing/transport/http"
)
func TestResolveAuthMethodPrefersExplicitToken(t *testing.T) {
ep, err := transport.NewEndpoint("https://github.com/entireio/cli.git")
if err != nil {
t.Fatalf("new endpoint: %v", err)
}
originalFill := gitCredentialFillCommand
t.Cleanup(func() {
gitCredentialFillCommand = originalFill
})
gitCredentialFillCommand = func(ctx context.Context, input string) ([]byte, error) {
t.Fatalf("unexpected git credential fill call with input %q", input)
return nil, nil
}
auth, err := resolveAuthMethod(Endpoint{
Username: "git",
Token: "explicit-token",
}, ep)
if err != nil {
t.Fatalf("resolve auth: %v", err)
}
basic, ok := auth.(*transporthttp.BasicAuth)
if !ok {
t.Fatalf("expected basic auth, got %T", auth)
}
if basic.Username != "git" || basic.Password != "explicit-token" {
t.Fatalf("unexpected auth: %+v", basic)
}
}
Ainternal/syncer/auth_test.go+41
216 unmodified lines
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
327 unmodified lines
598
599
600
601
602
603
604
605
19 unmodified lines
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
69 unmodified lines
709
710
711
712
713
714
715
716
717
718
719
720
721
722
216 unmodified lines
}
}
func TestRun_IntegrationUsesGitCredentialHelperFallback(t *testing.T) {
sourceRepo, sourceFS := newSourceRepo(t)
makeCommits(t, sourceRepo, sourceFS, 2)
targetRepo, err := git.Init(memory.NewStorage(), nil)
if err != nil {
t.Fatalf("init target repo: %v", err)
}
const username = "oauth2"
const password = "helper-secret"
sourceServer := newAuthenticatedSmartHTTPRepoServer(t, sourceRepo, username, password)
targetServer := newAuthenticatedSmartHTTPRepoServer(t, targetRepo, username, password)
defer sourceServer.Close()
defer targetServer.Close()
originalFill := gitCredentialFillCommand
t.Cleanup(func() {
gitCredentialFillCommand = originalFill
})
gitCredentialFillCommand = func(ctx context.Context, input string) ([]byte, error) {
if !strings.Contains(input, "protocol=http\n") {
t.Fatalf("expected protocol in credential input, got %q", input)
}
if !strings.Contains(input, "host=") {
t.Fatalf("expected host in credential input, got %q", input)
}
if !strings.Contains(input, "path=repo.git\n") {
t.Fatalf("expected repo path in credential input, got %q", input)
}
return []byte("username=" + username + "\npassword=" + password + "\n\n"), nil
}
result, err := Run(context.Background(), Config{
Source: Endpoint{URL: sourceServer.RepoURL()},
Target: Endpoint{URL: targetServer.RepoURL()},
})
if err != nil {
t.Fatalf("sync with credential helper failed: %v", err)
}
if result.Pushed != 1 || result.Blocked != 0 {
t.Fatalf("unexpected result: %+v", result)
}
assertHeadsMatch(t, sourceRepo, targetRepo, testBranch)
}
func TestRun_IntegrationProtocolV2Source(t *testing.T) {
sourceRepo, sourceFS := newSourceRepo(t)
makeCommits(t, sourceRepo, sourceFS, 4)
327 unmodified lines
repo *git.Repository
repoPath string
v2 bool
username string
password string
mu sync.Mutex
metrics []exchangeMetric
19 unmodified lines
return s
}
func newAuthenticatedSmartHTTPRepoServer(t *testing.T, repo *git.Repository, username, password string) *smartHTTPRepoServer {
t.Helper()
s := newSmartHTTPRepoServer(t, repo)
s.username = username
s.password = password
return s
}
func (s *smartHTTPRepoServer) Close() {
s.server.Close()
}
69 unmodified lines
}
func (s *smartHTTPRepoServer) handle(w http.ResponseWriter, r *http.Request) {
if s.username != "" || s.password != "" {
username, password, ok := r.BasicAuth()
if !ok || username != s.username || password != s.password {
w.Header().Set("WWW-Authenticate", `Basic realm="git-sync-test"`)
http.Error(w, "authentication required", http.StatusUnauthorized)
return
}
}
switch {
case r.Method == http.MethodGet && r.URL.Path == s.repoPath+"/info/refs":
s.handleInfoRefs(w, r)
Minternal/syncer/integration_test.go+67
536 unmodified lines
537
538
539
540
540
541
542
543
60 unmodified lines
604
605
606
607
607
608
609
610
536 unmodified lines
if gitProtocol != "" {
req.Header.Set("Git-Protocol", gitProtocol)
}
applyAuth(req, conn.raw)
applyAuth(req, conn.authMethod())
res, err := conn.http.Do(req)
if err != nil {
60 unmodified lines
if gitProtocolV2 {
req.Header.Set("Git-Protocol", "version=2")
}
applyAuth(req, conn.raw)
applyAuth(req, conn.authMethod())
res, err := conn.http.Do(req)
if err != nil {
Minternal/syncer/protocol_v2.go+2/-2
1
2
3
4
5
6
7
1 unmodified line
9
10
11
12
13
14
15
1036 unmodified lines
1052
1053
1054
1055
1056
1057
1058
2 unmodified lines
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
9 unmodified lines
1080
1081
1082
1083
1084
1085
1086
1087
1088
1081
1089
1090
1091
1084
1085
1092
1093
1094
1095
1096
121 unmodified lines
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
package syncer
import (
"bytes"
"context"
"encoding/json"
"errors"
1 unmodified line
"io"
"net/http"
"os"
"os/exec"
"sort"
"strings"
1036 unmodified lines
transport transport.Transport
http *http.Client
raw Endpoint
auth transport.AuthMethod
stats *statsCollector
}
2 unmodified lines
if err != nil {
return nil, err
}
auth, err := resolveAuthMethod(raw, ep)
if err != nil {
return nil, err
}
httpClient := &http.Client{
Transport: &countingRoundTripper{
9 unmodified lines
transport: transporthttp.NewClient(httpClient),
http: httpClient,
raw: raw,
auth: auth,
stats: stats,
}, nil
}
func (c *transportConn) authMethod() transport.AuthMethod {
return c.raw.authMethod()
return c.auth
}
func applyAuth(req *http.Request, endpoint Endpoint) {
switch auth := endpoint.authMethod().(type) {
func applyAuth(req *http.Request, authMethod transport.AuthMethod) {
switch auth := authMethod.(type) {
case *transporthttp.BasicAuth:
auth.SetAuth(req)
case *transporthttp.TokenAuth:
121 unmodified lines
return nil
}
var gitCredentialFillCommand = func(ctx context.Context, input string) ([]byte, error) {
cmd := exec.CommandContext(ctx, "git", "credential", "fill")
cmd.Stdin = strings.NewReader(input)
return cmd.Output()
}
func resolveAuthMethod(raw Endpoint, ep *transport.Endpoint) (transport.AuthMethod, error) {
if auth := raw.authMethod(); auth != nil {
return auth, nil
}
if ep == nil {
return nil, nil
}
if ep.Protocol != "http" && ep.Protocol != "https" {
return nil, nil
}
username, password, ok := lookupGitCredential(ep)
if !ok {
return nil, nil
}
return &transporthttp.BasicAuth{Username: username, Password: password}, nil
}
func lookupGitCredential(ep *transport.Endpoint) (string, string, bool) {
input := credentialFillInput(ep)
if input == "" {
return "", "", false
}
output, err := gitCredentialFillCommand(context.Background(), input)
if err != nil {
return "", "", false
}
values := parseCredentialFillOutput(output)
password := values["password"]
if password == "" {
return "", "", false
}
username := values["username"]
if username == "" {
if ep.User != "" {
username = ep.User
} else {
username = "git"
}
}
return username, password, true
}
func credentialFillInput(ep *transport.Endpoint) string {
if ep == nil || ep.Host == "" {
return ""
}
var builder strings.Builder
builder.WriteString("protocol=")
builder.WriteString(ep.Protocol)
builder.WriteString("\n")
builder.WriteString("host=")
builder.WriteString(ep.Host)
builder.WriteString("\n")
if path := strings.TrimPrefix(ep.Path, "/"); path != "" {
builder.WriteString("path=")
builder.WriteString(path)
builder.WriteString("\n")
}
if ep.User != "" {
builder.WriteString("username=")
builder.WriteString(ep.User)
builder.WriteString("\n")
}
builder.WriteString("\n")
return builder.String()
}
func parseCredentialFillOutput(output []byte) map[string]string {
values := map[string]string{}
for _, line := range bytes.Split(output, []byte{'\n'}) {
line = bytes.TrimSpace(line)
if len(line) == 0 {
continue
}
key, value, ok := bytes.Cut(line, []byte{'='})
if !ok {
continue
}
values[string(key)] = string(value)
}
return values
}
func buildSidebandIfSupported(l *capability.List, reader io.Reader, p sideband.Progress) io.Reader {
var t sideband.Type
switch {