Add git credential helper fallback · Entire

Add git credential helper fallback

c487e35→

Sessions

939c30174771View transcript

Changes

5

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 {