Skip to content

Commit a874caf

Browse files
authored
fix/mirror-gerrit: encode clone URL credentials and validate the creds file (#1145)
1 parent 6e01b54 commit a874caf

2 files changed

Lines changed: 161 additions & 19 deletions

File tree

cmd/zoekt-mirror-gerrit/main.go

Lines changed: 38 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ package main
1818
import (
1919
"bytes"
2020
"context"
21+
"errors"
2122
"flag"
2223
"fmt"
2324
"io"
@@ -113,10 +114,13 @@ func main() {
113114
if *httpCrendentialsPath != "" {
114115
creds, err := os.ReadFile(*httpCrendentialsPath)
115116
if err != nil {
116-
log.Print("Cannot read gerrit http credentials, going Anonymous")
117+
log.Printf("Cannot read gerrit http credentials (%v), going Anonymous", err)
117118
} else {
118-
splitCreds := strings.Split(strings.TrimSpace(string(creds)), ":")
119-
rootURL.User = url.UserPassword(splitCreds[0], splitCreds[1])
119+
user, err := parseHTTPCredentials(string(creds))
120+
if err != nil {
121+
log.Fatalf("%s: %v", *httpCrendentialsPath, err)
122+
}
123+
rootURL.User = user
120124
}
121125
}
122126

@@ -141,16 +145,7 @@ func main() {
141145
log.Fatalf("GetServerInfo: %v", err)
142146
}
143147

144-
var projectURL string
145-
for _, s := range []string{"http", "anonymous http"} {
146-
if schemeInfo, ok := info.Download.Schemes[s]; ok {
147-
projectURL = schemeInfo.URL
148-
if s == "http" && schemeInfo.IsAuthRequired {
149-
projectURL = addPassword(projectURL, rootURL.User)
150-
}
151-
break
152-
}
153-
}
148+
projectURL, needsAuth := selectProjectURL(info)
154149
if projectURL == "" {
155150
log.Fatalf("project URL is empty, got Schemes %#v", info.Download.Schemes)
156151
}
@@ -180,9 +175,9 @@ func main() {
180175
continue
181176
}
182177

183-
cloneURL, err := url.Parse(strings.Replace(projectURL, "${project}", k, 1))
178+
cloneURL, err := buildCloneURL(projectURL, k, needsAuth, rootURL.User)
184179
if err != nil {
185-
log.Fatalf("url.Parse: %v", err)
180+
log.Fatalf("buildCloneURL: %v", err)
186181
}
187182

188183
name := filepath.Join(cloneURL.Host, cloneURL.Path)
@@ -267,10 +262,34 @@ func anonymousURL(u *url.URL) string {
267262
return anon.String()
268263
}
269264

270-
func addPassword(u string, user *url.Userinfo) string {
271-
password, _ := user.Password()
272-
username := user.Username()
273-
return strings.Replace(u, fmt.Sprintf("://%s@", username), fmt.Sprintf("://%s:%s@", username, password), 1)
265+
func selectProjectURL(info *gerrit.ServerInfo) (string, bool) {
266+
for _, s := range []string{"http", "anonymous http"} {
267+
if schemeInfo, ok := info.Download.Schemes[s]; ok {
268+
return schemeInfo.URL, s == "http" && schemeInfo.IsAuthRequired
269+
}
270+
}
271+
return "", false
272+
}
273+
274+
func buildCloneURL(projectURL, project string, needsAuth bool, user *url.Userinfo) (*url.URL, error) {
275+
u, err := url.Parse(strings.Replace(projectURL, "${project}", project, 1))
276+
if err != nil {
277+
return nil, err
278+
}
279+
// Leave the download scheme's own userinfo alone when no credentials were
280+
// configured: git can still resolve them via a credential helper or .netrc.
281+
if needsAuth && user != nil {
282+
u.User = user
283+
}
284+
return u, nil
285+
}
286+
287+
func parseHTTPCredentials(creds string) (*url.Userinfo, error) {
288+
split := strings.SplitN(strings.TrimSpace(creds), ":", 2)
289+
if len(split) != 2 {
290+
return nil, errors.New("expected format 'username:password'")
291+
}
292+
return url.UserPassword(split[0], split[1]), nil
274293
}
275294

276295
func addMetaConfigFetch(repoDir string) error {
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
package main
2+
3+
import (
4+
"net/url"
5+
"testing"
6+
7+
gerrit "github.com/andygrunwald/go-gerrit"
8+
)
9+
10+
func TestSelectProjectURL(t *testing.T) {
11+
httpScheme := gerrit.DownloadSchemeInfo{URL: "https://admin@gerrit.example.com/a/${project}"}
12+
anonScheme := gerrit.DownloadSchemeInfo{URL: "https://gerrit.example.com/a/${project}"}
13+
14+
for _, tc := range []struct {
15+
name string
16+
schemes map[string]gerrit.DownloadSchemeInfo
17+
wantURL string
18+
wantNeedsAuth bool
19+
}{
20+
{name: "http auth required", schemes: map[string]gerrit.DownloadSchemeInfo{"http": {URL: httpScheme.URL, IsAuthRequired: true}}, wantURL: httpScheme.URL, wantNeedsAuth: true},
21+
{name: "http no auth required", schemes: map[string]gerrit.DownloadSchemeInfo{"http": {URL: httpScheme.URL}}, wantURL: httpScheme.URL},
22+
{name: "anonymous http only", schemes: map[string]gerrit.DownloadSchemeInfo{"anonymous http": anonScheme}, wantURL: anonScheme.URL},
23+
{name: "no supported scheme", schemes: map[string]gerrit.DownloadSchemeInfo{"ssh": anonScheme}},
24+
} {
25+
t.Run(tc.name, func(t *testing.T) {
26+
info := &gerrit.ServerInfo{Download: gerrit.DownloadInfo{Schemes: tc.schemes}}
27+
gotURL, gotNeedsAuth := selectProjectURL(info)
28+
if gotURL != tc.wantURL || gotNeedsAuth != tc.wantNeedsAuth {
29+
t.Fatalf("selectProjectURL() = (%q, %v), want (%q, %v)", gotURL, gotNeedsAuth, tc.wantURL, tc.wantNeedsAuth)
30+
}
31+
})
32+
}
33+
}
34+
35+
// Regression for the reported bug: passwords containing '/' previously broke
36+
// url.Parse because they were spliced into the raw URL string.
37+
func TestBuildCloneURLRoundTripsPasswords(t *testing.T) {
38+
const template = "https://admin@gerrit.example.com/a/${project}"
39+
for _, pw := range []string{"secret", "abc/def", "ZOSOKjgV/kgEkN0bzPJp+oGeJLqpXykqWFJpon/Ckg", "p@ss#1?x"} {
40+
t.Run("password "+pw, func(t *testing.T) {
41+
u, err := buildCloneURL(template, "legacy/modules", true, url.UserPassword("admin", pw))
42+
if err != nil {
43+
t.Fatalf("buildCloneURL: %v", err)
44+
}
45+
46+
// git is handed cloneURL.String(), so assert on the serialized form
47+
// rather than on the fields buildCloneURL just assigned.
48+
got, err := url.Parse(u.String())
49+
if err != nil {
50+
t.Fatalf("url.Parse(%q): %v", u.String(), err)
51+
}
52+
if pass, ok := got.User.Password(); !ok || pass != pw {
53+
t.Fatalf("round-trip password = (%q, %v), want (%q, true)", pass, ok, pw)
54+
}
55+
if name := got.User.Username(); name != "admin" {
56+
t.Fatalf("round-trip username = %q, want \"admin\"", name)
57+
}
58+
if got.Host != "gerrit.example.com" || got.Path != "/a/legacy/modules" {
59+
t.Fatalf("host/path changed: %q/%q", got.Host, got.Path)
60+
}
61+
})
62+
}
63+
}
64+
65+
func TestBuildCloneURLAnonymous(t *testing.T) {
66+
u, err := buildCloneURL("https://gerrit.example.com/a/${project}", "p", false, nil)
67+
if err != nil {
68+
t.Fatal(err)
69+
}
70+
if u.User != nil {
71+
t.Fatalf("user = %v, want nil", u.User)
72+
}
73+
}
74+
75+
// Without --http-credentials rootURL.User is nil. The clone URL must keep the
76+
// username Gerrit put in the download scheme so git can still resolve the
77+
// password via a credential helper or .netrc.
78+
func TestBuildCloneURLKeepsSchemeUserWithoutCredentials(t *testing.T) {
79+
u, err := buildCloneURL("https://admin@gerrit.example.com/a/${project}", "legacy/modules", true, nil)
80+
if err != nil {
81+
t.Fatalf("buildCloneURL: %v", err)
82+
}
83+
if u.User == nil || u.User.Username() != "admin" {
84+
t.Fatalf("user = %v, want admin", u.User)
85+
}
86+
if _, ok := u.User.Password(); ok {
87+
t.Fatalf("password set, want none: %q", u.String())
88+
}
89+
}
90+
91+
func TestParseHTTPCredentials(t *testing.T) {
92+
for _, tc := range []struct {
93+
name string
94+
creds string
95+
wantUser string
96+
wantPass string
97+
wantError bool
98+
}{
99+
{name: "simple", creds: "admin:secret", wantUser: "admin", wantPass: "secret"},
100+
{name: "password containing colon", creds: "admin:pa:ss", wantUser: "admin", wantPass: "pa:ss"},
101+
{name: "password containing slash", creds: "admin:abc/def", wantUser: "admin", wantPass: "abc/def"},
102+
{name: "surrounding whitespace", creds: "\n admin:pa:ss\n", wantUser: "admin", wantPass: "pa:ss"},
103+
{name: "empty password", creds: "admin:", wantUser: "admin", wantPass: ""},
104+
{name: "no colon", creds: "adminonly", wantError: true},
105+
} {
106+
t.Run(tc.name, func(t *testing.T) {
107+
user, err := parseHTTPCredentials(tc.creds)
108+
if tc.wantError {
109+
if err == nil {
110+
t.Fatalf("parseHTTPCredentials(%q) succeeded, want error", tc.creds)
111+
}
112+
return
113+
}
114+
if err != nil {
115+
t.Fatalf("parseHTTPCredentials(%q): %v", tc.creds, err)
116+
}
117+
pass, ok := user.Password()
118+
if user.Username() != tc.wantUser || !ok || pass != tc.wantPass {
119+
t.Fatalf("got (%q, %q, %v), want (%q, %q, true)", user.Username(), pass, ok, tc.wantUser, tc.wantPass)
120+
}
121+
})
122+
}
123+
}

0 commit comments

Comments
 (0)