diff --git a/CHANGELOG.md b/CHANGELOG.md index f0fd6ec..b19642b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- Match multi-level credential path scopes only at slash boundaries, preventing + similarly prefixed groups or repositories from receiving the wrong token. +- Match Git's credential URL parsing and decoding behavior, including literal + plus signs, single-pass percent decoding, and query or fragment boundaries. +- Keep passwords and malformed credential lines out of debug logs, and set + debug log file permissions to `0600` on POSIX systems. + ## [1.1.2] - 2026-09-03 ### Fixed diff --git a/README.md b/README.md index d102c62..15275c6 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,10 @@ See the complete, sanitized [`examples/gitconfig`](examples/gitconfig) file: [credential "https://git.example.com/"] helper = readonly --file ~/.git-credentials-work +[credential "https://gitlab.example.com/"] + helper = readonly --file ~/.git-credentials-work + useHttpPath = true + [credential] helper = readonly ``` @@ -97,15 +101,26 @@ file names as needed. ### Credential file examples -When `useHttpPath = true`, include a GitHub account or organization path in -each credential URL. An owner-only entry matches every repository belonging to -that owner, while a full repository path matches only that repository. +Git removes the HTTP(S) path before invoking external helpers unless +[`credential.useHttpPath`](https://git-scm.com/docs/gitcredentials#Documentation/gitcredentials.txt-credentialuseHttpPath) +is enabled. Git's built-in `credential-store` then compares a supplied path +exactly. This helper intentionally extends that behavior with slash-delimited +path scopes: `group/subgroup` matches both itself and descendants such as +`group/subgroup/project.git`, but it does not match `group/subgroup-backup`. +A trailing slash on a credential path is optional. Non-exact scope matches are +rejected when either path contains a `.` or `..` segment, including a +percent-encoded form that remains after Git's URL decoding. + +Credential lines are checked from top to bottom, like `credential-store`, and +the first match wins. Put full repository paths before subgroup paths, and put +subgroup paths before broader organization or account paths. `~/.git-credentials-work`: ```text -https://example-user:organization-token@github.com/example-org https://example-user:repository-token@github.com/example-org/private-repository.git +https://example-user:organization-token@github.com/example-org +https://example-user:subgroup-token@gitlab.example.com/group/subgroup https://example-user:work-token@git.example.com ``` @@ -117,8 +132,11 @@ The default personal file may contain: https://example-user:personal-token@github.com/example-user ``` -Credential files contain plaintext secrets. Never commit them, percent-encode -special characters in usernames and tokens, and restrict their permissions: +Credential files use the official +[`git-credential-store` storage format](https://git-scm.com/docs/git-credential-store#_storage_format): +one credential URL per line, without comments or blank lines. They contain +plaintext secrets. Never commit them, percent-encode special characters in +usernames and tokens, and restrict their permissions: ```shell chmod 600 ~/.git-credentials ~/.git-credentials-work @@ -158,5 +176,12 @@ git config --show-origin --show-scope \ ## Documentation - [Git credential storage](https://git-scm.com/book/en/v2/Git-Tools-Credential-Storage#_a_custom_credential_cache) +- [`git credential` input/output format](https://git-scm.com/docs/git-credential#_inputoutput_format) +- [`git-credential-store` storage and lookup behavior](https://git-scm.com/docs/git-credential-store#_storage_format) - [`credential.helper` configuration](https://git-scm.com/docs/gitcredentials#Documentation/gitcredentials.txt-credentialhelper) - [`credential.useHttpPath` configuration](https://git-scm.com/docs/gitcredentials#Documentation/gitcredentials.txt-credentialuseHttpPath) +- [Git credential-context matching](https://git-scm.com/docs/gitcredentials#Documentation/gitcredentials.txt-CREDENTIALCONTEXTS) +- [Git's exact credential field matcher](https://github.com/git/git/blob/v2.55.0/credential.c#L81-L92) +- [Git's `credential-store` lookup loop](https://github.com/git/git/blob/v2.55.0/builtin/credential-store.c#L13-L47) +- [Git's credential URL parser](https://github.com/git/git/blob/v2.55.0/credential.c#L585-L653) +- [Git's credential URL decoder](https://github.com/git/git/blob/v2.55.0/url.c#L44-L102) diff --git a/examples/gitconfig b/examples/gitconfig index c677110..9644f23 100644 --- a/examples/gitconfig +++ b/examples/gitconfig @@ -17,6 +17,11 @@ [credential "https://git.example.com/"] helper = readonly --file ~/.git-credentials-work +# Optional GitLab host demonstrating a multi-level group credential scope. +[credential "https://gitlab.example.com/"] + helper = readonly --file ~/.git-credentials-work + useHttpPath = true + # General personal credential fallback. Keep this after scoped helpers. [credential] helper = readonly diff --git a/main.go b/main.go index 8b6cfce..c5aa8e9 100644 --- a/main.go +++ b/main.go @@ -7,7 +7,6 @@ import ( "fmt" "io" "log" - "net/url" "os" "os/user" "strings" @@ -36,10 +35,11 @@ func main() { if err != nil { log.Fatal(err) } - logOut, err := os.OpenFile(logFile, os.O_CREATE|os.O_APPEND|os.O_WRONLY, os.ModePerm) + logOut, err := openDebugLog(logFile) if err != nil { log.Fatal(err) } + defer logOut.Close() log.SetOutput(logOut) } else { log.SetOutput(io.Discard) @@ -61,13 +61,13 @@ func main() { if err != nil { log.Fatalf("get stdin failed, err=%v", err) } - log.Printf("get req success: %#v", req) + logCredentialMetadata("get request", req) credential := getCredential(req, credFile) if credential == nil { // credential not found os.Exit(1) } - log.Printf("get credential success: %#v", credential) + logCredentialMetadata("get credential success", credential) fmt.Printf("username=%s\npassword=%s\n", credential.username, credential.password) case "erase", "store": log.Printf("ignore action=%v", action) @@ -85,6 +85,23 @@ type credential struct { path string } +func openDebugLog(path string) (*os.File, error) { + logOut, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600) + if err != nil { + return nil, err + } + if err := logOut.Chmod(0o600); err != nil { + logOut.Close() + return nil, err + } + return logOut, nil +} + +func logCredentialMetadata(event string, credential *credential) { + log.Printf("%s: protocol=%q,host=%q,path=%q,username=%q", + event, credential.protocol, credential.host, credential.path, credential.username) +} + func (c *credential) match(req *credential) bool { if c == nil || req == nil { return false @@ -102,24 +119,51 @@ func (c *credential) match(req *credential) bool { if req.path != "" { pathMatch := matchCredentialPath(c.path, req.path) match = match && pathMatch - log.Printf("match path: req.path=%v,config.path=%v,result=%v", + log.Printf("match path: req.path=%q,config.path=%q,result=%v", req.path, c.path, match) } return match } func matchCredentialPath(configPath, requestPath string) bool { + // Keep Git's exact-match behavior, including the encoded-root path "/", + // before normalizing optional trailing separators for scope matching. + if configPath != "" && configPath == requestPath { + return true + } + configPath = strings.TrimRight(configPath, "/") requestPath = strings.TrimRight(requestPath, "/") - // Preserve the standard credential-store behavior for repository-specific - // entries before trying the owner/organization shorthand supported here. + if configPath == "" || requestPath == "" { + return false + } + if configPath == requestPath { return true } - requestOwner, _, hasSeparator := strings.Cut(requestPath, "/") - return hasSeparator && requestOwner != "" && configPath == requestOwner + // Scope matching is an extension to credential-store's exact path match. + // Refuse ambiguous dot segments so a path cannot escape a matched scope. + if hasDotPathSegment(configPath) || hasDotPathSegment(requestPath) { + return false + } + + return len(requestPath) > len(configPath) && + strings.HasPrefix(requestPath, configPath) && + requestPath[len(configPath)] == '/' +} + +func hasDotPathSegment(path string) bool { + // Git's decoder may preserve escapes before a literal ':', while the URL + // consumer can still normalize them. Use a fully decoded safety view so an + // encoded dot or slash cannot hide a path traversal from scope matching. + for _, segment := range strings.Split(decodePercentEscapes(path), "/") { + if segment == "." || segment == ".." { + return true + } + } + return false } func parseGitCredentialRequest(r io.Reader) (*credential, error) { @@ -157,52 +201,38 @@ func parseGitCredentialRequest(r io.Reader) (*credential, error) { } func parseCredential(line string) *credential { - fields := strings.SplitN(line, "://", 2) - if len(fields) != 2 { + protoEnd := strings.Index(line, "://") + if protoEnd <= 0 { // malformed line, ignore return nil } - proto := fields[0] - rest := fields[1] + proto := line[:protoEnd] + rest := line[protoEnd+3:] - fields = strings.SplitN(rest, "@", 2) - if len(fields) != 2 { - // malformed line, ignore - return nil + hostEnd := strings.IndexAny(rest, "/?#") + if hostEnd < 0 { + hostEnd = len(rest) } - - auth := fields[0] - credFields := strings.SplitN(auth, ":", 2) - if len(credFields) != 2 { + at := strings.IndexByte(rest, '@') + colon := strings.IndexByte(rest, ':') + if at < 0 || hostEnd <= at || colon < 0 || at <= colon { // malformed line, ignore return nil } - username, err := url.QueryUnescape(credFields[0]) - if err != nil { - return nil - } - password, err := url.QueryUnescape(credFields[1]) - if err != nil { - return nil - } + username := decodeCredentialURLComponent(rest[:colon]) + password := decodeCredentialURLComponent(rest[colon+1 : at]) + host := decodeCredentialURLComponent(rest[at+1 : hostEnd]) - hostAndPath := fields[1] - hostFields := strings.SplitN(hostAndPath, "/", 2) - if len(hostFields) != 1 && len(hostFields) != 2 { - // malformed line, ignore - return nil - } - host, err := url.QueryUnescape(hostFields[0]) - if err != nil { - return nil + var path string + if hostEnd < len(rest) { + rawPath := strings.TrimLeft(rest[hostEnd:], "/") + path = decodeCredentialURLComponent(rawPath) + path = trimCredentialURLPath(path) } - var path string - if len(hostFields) == 2 { - path, err = url.QueryUnescape(hostFields[1]) - if err != nil { - return nil - } + if strings.Contains(username, "\n") || strings.Contains(password, "\n") || + strings.Contains(host, "\n") || strings.Contains(path, "\n") { + return nil } return &credential{ @@ -214,6 +244,67 @@ func parseCredential(line string) *credential { } } +func trimCredentialURLPath(path string) string { + trimmed := strings.TrimRight(path, "/") + if path != "" && trimmed == "" { + return "/" + } + return trimmed +} + +// decodeCredentialURLComponent follows Git's url_decode_mem: a prefix before +// the first literal ':' is preserved as a possible URL scheme; the remainder +// decodes valid, non-NUL percent escapes exactly once and leaves '+' and +// malformed escapes unchanged. +func decodeCredentialURLComponent(value string) string { + colon := strings.IndexByte(value, ':') + if colon <= 0 { + return decodePercentEscapes(value) + } + + var decoded strings.Builder + decoded.Grow(len(value)) + decoded.WriteString(value[:colon]) + decoded.WriteString(decodePercentEscapes(value[colon:])) + return decoded.String() +} + +func decodePercentEscapes(value string) string { + var decoded strings.Builder + decoded.Grow(len(value)) + + for i := 0; i < len(value); i++ { + if value[i] == '%' && i+2 < len(value) { + high, highOK := hexValue(value[i+1]) + low, lowOK := hexValue(value[i+2]) + if highOK && lowOK { + unescaped := high<<4 | low + if unescaped != 0 { + decoded.WriteByte(unescaped) + i += 2 + continue + } + } + } + decoded.WriteByte(value[i]) + } + + return decoded.String() +} + +func hexValue(value byte) (byte, bool) { + switch { + case value >= '0' && value <= '9': + return value - '0', true + case value >= 'a' && value <= 'f': + return value - 'a' + 10, true + case value >= 'A' && value <= 'F': + return value - 'A' + 10, true + default: + return 0, false + } +} + func getCredential(req *credential, credFile string) *credential { credPath, err := expandHomeDir(credFile) if err != nil { @@ -228,11 +319,13 @@ func getCredential(req *credential, credFile string) *credential { defer file.Close() scanner := bufio.NewScanner(file) + lineNumber := 0 for scanner.Scan() { + lineNumber++ line := scanner.Text() cred := parseCredential(line) if cred == nil { - log.Printf("err malformed credential line: %s", line) + log.Printf("ignore malformed credential at line %d", lineNumber) continue } if cred.match(req) { diff --git a/main_test.go b/main_test.go index ca5a0c2..91436a0 100644 --- a/main_test.go +++ b/main_test.go @@ -1,30 +1,106 @@ package main import ( - "fmt" + "bytes" + "log" "os" "path/filepath" + "runtime" "strings" "testing" ) -func TestGetCredential(t *testing.T) { +func writeCredentialFile(t *testing.T, credentials ...string) string { + t.Helper() + credFile := filepath.Join(t.TempDir(), "credentials") - credentials := []string{ - "https://john:password@github.com/foo/bar", - "https://octocat:org-password@github.com/acme", - "https://jane:password@bitbucket.org/foo/bar.git", + contents := strings.Join(credentials, "\n") + if len(credentials) > 0 { + contents += "\n" } - file, err := os.Create(credFile) - if err != nil { + if err := os.WriteFile(credFile, []byte(contents), 0o600); err != nil { t.Fatal(err) } - for _, value := range credentials { - fmt.Fprintln(file, value) + return credFile +} + +func TestOpenDebugLogRestrictsPermissions(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Windows uses ACLs rather than POSIX permission bits") } - if err := file.Close(); err != nil { - t.Fatal(err) + + for _, existing := range []bool{false, true} { + name := "new file" + if existing { + name = "existing file" + } + t.Run(name, func(t *testing.T) { + logPath := filepath.Join(t.TempDir(), "debug.log") + if existing { + if err := os.WriteFile(logPath, []byte("existing log\n"), 0o666); err != nil { + t.Fatal(err) + } + if err := os.Chmod(logPath, 0o666); err != nil { + t.Fatal(err) + } + } + + logFile, err := openDebugLog(logPath) + if err != nil { + t.Fatalf("open debug log: %v", err) + } + if err := logFile.Close(); err != nil { + t.Fatalf("close debug log: %v", err) + } + + info, err := os.Stat(logPath) + if err != nil { + t.Fatalf("stat debug log: %v", err) + } + if got := info.Mode().Perm(); got != 0o600 { + t.Errorf("debug log permissions = %04o, want 0600", got) + } + }) } +} + +func TestLogCredentialMetadataOmitsPasswordAndEscapesControls(t *testing.T) { + var logOutput bytes.Buffer + previousLogOutput := log.Writer() + previousLogFlags := log.Flags() + log.SetOutput(&logOutput) + log.SetFlags(0) + t.Cleanup(func() { + log.SetOutput(previousLogOutput) + log.SetFlags(previousLogFlags) + }) + + logCredentialMetadata("credential", &credential{ + protocol: "https", + username: "user", + password: "secret-token", + host: "gitlab.com", + path: "group/\rrepository.git", + }) + + got := logOutput.String() + if strings.Contains(got, "secret-token") { + t.Fatal("credential password leaked to the log") + } + if strings.Contains(got, "\r") { + t.Fatal("credential path control character was written literally") + } + if !strings.Contains(got, `path="group/\rrepository.git"`) { + t.Errorf("credential path was not safely quoted: %q", got) + } +} + +func TestGetCredential(t *testing.T) { + credFile := writeCredentialFile(t, + "https://john:password@github.com/foo/bar", + "https://octocat:org-password@github.com/acme", + "https://jane:password@bitbucket.org/foo/bar.git", + ) tests := []struct { name string @@ -66,6 +142,131 @@ func TestGetCredential(t *testing.T) { } } +func TestGetCredentialNestedPathScopes(t *testing.T) { + credFile := writeCredentialFile(t, + "https://USERNAME:TOKEN1@gitlab.com/group/subgroup1/project.git", + "https://USERNAME:TOKEN2@gitlab.com/group/subgroup2/project2.git", + "https://USERNAME:TOKEN3@gitlab.com/group/subgroup2/", + "https://USERNAME:TOKEN4@gitlab.com/group/", + ) + + tests := []struct { + name string + path string + wantPassword string + }{ + { + name: "exact repository wins when listed first", + path: "group/subgroup2/project2.git", + wantPassword: "TOKEN2", + }, + { + name: "nested subgroup scope", + path: "group/subgroup2/another.git", + wantPassword: "TOKEN3", + }, + { + name: "segment collision falls back to parent scope", + path: "group/subgroup20/project.git", + wantPassword: "TOKEN4", + }, + { + name: "similar top-level group does not match", + path: "group-backup/project.git", + }, + { + name: "missing request path preserves host-only behavior", + wantPassword: "TOKEN1", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := getCredential(&credential{ + protocol: "https", + host: "gitlab.com", + path: tt.path, + username: "USERNAME", + }, credFile) + if tt.wantPassword == "" { + if got != nil { + t.Fatalf("unexpected credential: %+v", got) + } + return + } + if got == nil { + t.Fatal("expected to find a credential") + } + if got.password != tt.wantPassword { + t.Errorf("password = %q, want %q", got.password, tt.wantPassword) + } + }) + } +} + +func TestGetCredentialKeepsFirstMatchPrecedence(t *testing.T) { + credFile := writeCredentialFile(t, + "https://user:group-token@gitlab.com/group", + "https://user:repository-token@gitlab.com/group/subgroup/project.git", + ) + + got := getCredential(&credential{ + protocol: "https", + host: "gitlab.com", + path: "group/subgroup/project.git", + username: "user", + }, credFile) + if got == nil { + t.Fatal("expected to find a credential") + } + if got.password != "group-token" { + t.Errorf("password = %q, want first matching credential", got.password) + } +} + +func TestGetCredentialSkipsMalformedLines(t *testing.T) { + credFile := writeCredentialFile(t, + "# comments are not part of the credential-store format", + "// this is also just a malformed URL", + "malformed-token-value", + "https://user:token@gitlab.com/group/repo.git", + ) + + var logOutput bytes.Buffer + previousLogOutput := log.Writer() + log.SetOutput(&logOutput) + t.Cleanup(func() { log.SetOutput(previousLogOutput) }) + + got := getCredential(&credential{ + protocol: "https", + host: "gitlab.com", + path: "group/repo.git", + username: "user", + }, credFile) + if got == nil || got.password != "token" { + t.Fatalf("unexpected credential: %+v", got) + } + if strings.Contains(logOutput.String(), "malformed-token-value") { + t.Fatal("malformed credential contents leaked to the log") + } +} + +func TestGetCredentialRejectsEncodedDotSegmentScope(t *testing.T) { + credFile := writeCredentialFile(t, + "https://user:token@gitlab.com/group/%2E%2E/", + ) + + got := getCredential(&credential{ + protocol: "https", + host: "gitlab.com", + path: "group/../private/repo.git", + username: "user", + }, credFile) + if got != nil { + t.Fatalf("unexpected credential: %+v", got) + } +} + func TestMatchCredentialPath(t *testing.T) { tests := []struct { name string @@ -80,9 +281,9 @@ func TestMatchCredentialPath(t *testing.T) { want: true, }, { - name: "owner path", - configPath: "acme", - requestPath: "acme/widgets.git", + name: "nested group scope", + configPath: "acme/platform", + requestPath: "acme/platform/widgets.git", want: true, }, { @@ -92,15 +293,15 @@ func TestMatchCredentialPath(t *testing.T) { want: true, }, { - name: "different repository", - configPath: "acme/widgets.git", - requestPath: "acme/gadgets.git", + name: "path segment collision", + configPath: "acme/platform", + requestPath: "acme/platform-tools/widgets.git", want: false, }, { - name: "different owner", - configPath: "acme", - requestPath: "other/widgets.git", + name: "repository name collision", + configPath: "acme/widget", + requestPath: "acme/widgets", want: false, }, { @@ -108,6 +309,53 @@ func TestMatchCredentialPath(t *testing.T) { requestPath: "acme/widgets.git", want: false, }, + { + name: "empty request path", + configPath: "acme", + want: false, + }, + { + name: "configured path is more specific", + configPath: "acme/platform/widgets.git", + requestPath: "acme/platform", + want: false, + }, + { + name: "dot segment cannot inherit scope", + configPath: "acme/platform", + requestPath: "acme/platform/../private/widgets.git", + want: false, + }, + { + name: "encoded dot segment cannot inherit scope", + configPath: "acme/platform", + requestPath: "acme/platform/%2e%2E/private:widgets.git", + want: false, + }, + { + name: "exact path with dot segment remains exact", + configPath: "acme/platform/../private/widgets.git", + requestPath: "acme/platform/../private/widgets.git", + want: true, + }, + { + name: "slash-only config path is not a scope", + configPath: "/", + requestPath: "acme/platform/widgets.git", + want: false, + }, + { + name: "encoded root path remains exact", + configPath: "/", + requestPath: "/", + want: true, + }, + { + name: "path matching is case sensitive", + configPath: "acme/platform", + requestPath: "Acme/platform/widgets.git", + want: false, + }, } for _, tt := range tests { @@ -120,6 +368,176 @@ func TestMatchCredentialPath(t *testing.T) { } } +func TestParseCredentialPreservesLiteralPlus(t *testing.T) { + want := credential{ + protocol: "https", + username: "user+name", + password: "token+value", + host: "gitlab.com", + path: "group/repo+name.git", + } + + for _, line := range []string{ + "https://user+name:token+value@gitlab.com/group/repo+name.git", + "https://user%2Bname:token%2Bvalue@gitlab.com/group/repo%2Bname.git", + } { + t.Run(line, func(t *testing.T) { + got := parseCredential(line) + if got == nil { + t.Fatal("expected a valid credential") + } + if *got != want { + t.Errorf("credential = %+v, want %+v", *got, want) + } + }) + } +} + +func TestParseCredentialMatchesGitPercentDecoding(t *testing.T) { + tests := []struct { + name string + line string + want credential + }{ + { + name: "decode once", + line: "https://user%40example.com:token%3Avalue@gitlab.com/group%2Fsubgroup/repo%252Fname.git", + want: credential{ + protocol: "https", + username: "user@example.com", + password: "token:value", + host: "gitlab.com", + path: "group/subgroup/repo%2Fname.git", + }, + }, + { + name: "preserve malformed and NUL escapes", + line: "https://user%GG:token%2@gitlab.com/group/%00repo.git", + want: credential{ + protocol: "https", + username: "user%GG", + password: "token%2", + host: "gitlab.com", + path: "group/%00repo.git", + }, + }, + { + name: "trim URL path slashes", + line: "https://user:token@gitlab.com///group/subgroup///", + want: credential{ + protocol: "https", + username: "user", + password: "token", + host: "gitlab.com", + path: "group/subgroup", + }, + }, + { + name: "preserve possible scheme prefixes", + line: "https://user:tok%65n:value%41@gitlab%2Eexample:443/group%2Fsub:repo%2Fname.git", + want: credential{ + protocol: "https", + username: "user", + password: "tok%65n:valueA", + host: "gitlab%2Eexample:443", + path: "group%2Fsub:repo/name.git", + }, + }, + { + name: "preserve encoded root path", + line: "https://user:token@gitlab.com/%2F%2F", + want: credential{ + protocol: "https", + username: "user", + password: "token", + host: "gitlab.com", + path: "/", + }, + }, + { + name: "query marker starts path without a slash", + line: "https://user:token@gitlab.com?service=git-upload-pack", + want: credential{ + protocol: "https", + username: "user", + password: "token", + host: "gitlab.com", + path: "?service=git-upload-pack", + }, + }, + { + name: "fragment marker starts path without a slash", + line: "https://user:token@gitlab.com#credential-scope", + want: credential{ + protocol: "https", + username: "user", + password: "token", + host: "gitlab.com", + path: "#credential-scope", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := parseCredential(tt.line) + if got == nil { + t.Fatal("expected a valid credential") + } + if *got != tt.want { + t.Errorf("credential = %+v, want %+v", *got, tt.want) + } + }) + } +} + +func TestParseCredentialRejectsEncodedNewline(t *testing.T) { + if got := parseCredential("https://user:token%0Asecret@gitlab.com/group/repo.git"); got != nil { + t.Fatalf("unexpected credential: %+v", got) + } +} + +func TestParseCredentialRejectsInvalidStoreURLs(t *testing.T) { + for _, line := range []string{ + "://user:token@gitlab.com/group/repo.git", + "https://gitlab.com/group/repo.git", + "https://user@gitlab.com/group/repo.git", + "https://gitlab.com?query:user@evil.example/repo.git", + "https://gitlab.com#fragment:user@evil.example/repo.git", + } { + t.Run(line, func(t *testing.T) { + if got := parseCredential(line); got != nil { + t.Fatalf("unexpected credential: %+v", got) + } + }) + } +} + +func TestHasDotPathSegment(t *testing.T) { + tests := map[string]bool{ + ".": true, + "..": true, + "%2e": true, + "%2E": true, + ".%2e": true, + "%2e.": true, + "%2e%2E": true, + "group/%2e%2e": true, + "group/%2e%2e%2Foutside:foo": true, + "...": false, + "%252e": false, + "repository": false, + } + + for path, want := range tests { + t.Run(path, func(t *testing.T) { + if got := hasDotPathSegment(path); got != want { + t.Errorf("hasDotPathSegment(%q) = %v, want %v", path, got, want) + } + }) + } +} + func TestParseGitCredentialRequest(t *testing.T) { want := credential{ protocol: "https",