-
Notifications
You must be signed in to change notification settings - Fork 188
fix: resolve credentials for protected HTTP(S) repositories #3637
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -12,6 +12,8 @@ import ( | |
| "github.com/go-git/go-billy/v5/memfs" | ||
| "github.com/go-git/go-git/v5" | ||
| "github.com/go-git/go-git/v5/plumbing" | ||
| "github.com/go-git/go-git/v5/plumbing/transport" | ||
| githttp "github.com/go-git/go-git/v5/plumbing/transport/http" | ||
| "github.com/go-git/go-git/v5/storage/memory" | ||
| "gopkg.in/yaml.v2" | ||
|
|
||
|
|
@@ -189,11 +191,14 @@ func isNonBareGitRepo(uri string) bool { | |
| // FilesystemFromRepo attempts to fetch a filesystem from a git repository | ||
| // indicated by the given URI. Returns nil if there is not a repo at the URI. | ||
| func FilesystemFromRepo(uri string) (filesystem.Filesystem, error) { | ||
| clone, err := git.Clone( | ||
| memory.NewStorage(), | ||
| memfs.New(), | ||
| getGitCloneOptions(uri), | ||
| ) | ||
| opts := getGitCloneOptions(uri) | ||
| clone, err := git.Clone(memory.NewStorage(), memfs.New(), opts) | ||
| if isAuthError(err) { | ||
| if auth := credentialsForURL(opts.URL); auth != nil { | ||
| opts.Auth = auth | ||
| clone, err = git.Clone(memory.NewStorage(), memfs.New(), opts) | ||
| } | ||
| } | ||
| if err != nil { | ||
| if isRepoNotFoundError(err) { | ||
| return nil, nil | ||
|
|
@@ -438,6 +443,196 @@ func checkDir(fs filesystem.Filesystem, path string) error { | |
| return err | ||
| } | ||
|
|
||
| // credentialsForURL returns HTTP basic-auth credentials for the given URL by | ||
| // consulting, in order: | ||
| // 1. ~/.git-credentials (written by git's "store" credential helper) | ||
| // 2. ~/.netrc | ||
| // | ||
| // Returns nil when the URL is not HTTP(S) or no matching entry is found. | ||
| // No subprocesses are spawned; both files are read with pure Go. | ||
| func credentialsForURL(rawURL string) transport.AuthMethod { | ||
| u, err := url.Parse(rawURL) | ||
| if err != nil || (u.Scheme != "http" && u.Scheme != "https") { | ||
| return nil | ||
| } | ||
| if auth := credentialsFromGitStore(u); auth != nil { | ||
| return auth | ||
| } | ||
| return credentialsFromNetRC(u) | ||
| } | ||
|
|
||
| // credentialsFromGitStore reads ~/.git-credentials (the git credential store | ||
| // format). Each non-blank line is a URL with embedded user info: | ||
| // | ||
| // https://user:password@host | ||
| // | ||
| // Matching rules (mirrors git's own credential matching with one relaxation): | ||
| // - Hostname must match exactly. | ||
| // - Port must match. Implicit default ports (80 for http, 443 for https) are | ||
| // normalised to empty so that http://example.com credentials work for | ||
| // https://example.com (and vice-versa), but http://example.com:8080 does | ||
| // NOT match http://example.com. | ||
| // - Scheme is intentionally not checked: http:// and https:// are treated as | ||
| // interchangeable for BasicAuth purposes. | ||
| // | ||
| // The first matching entry wins. | ||
| func credentialsFromGitStore(u *url.URL) transport.AuthMethod { | ||
| home, err := os.UserHomeDir() | ||
| if err != nil { | ||
| return nil | ||
| } | ||
| data, err := os.ReadFile(filepath.Join(home, ".git-credentials")) | ||
| if err != nil { | ||
| return nil | ||
| } | ||
| targetPort := gitCredentialPort(u) | ||
| for _, line := range strings.Split(string(data), "\n") { | ||
| line = strings.TrimSpace(line) | ||
| if line == "" { | ||
| continue | ||
| } | ||
| entry, err := url.Parse(line) | ||
| if err != nil { | ||
| continue | ||
| } | ||
| if entry.Hostname() != u.Hostname() { | ||
| continue | ||
| } | ||
| if gitCredentialPort(entry) != targetPort { | ||
| continue | ||
|
Ankitsinghsisodya marked this conversation as resolved.
|
||
| } | ||
| if entry.User == nil { | ||
| continue | ||
| } | ||
| username := entry.User.Username() | ||
| password, hasPass := entry.User.Password() | ||
| if !hasPass && username == "" { | ||
| continue | ||
| } | ||
| return &githttp.BasicAuth{Username: username, Password: password} | ||
| } | ||
| return nil | ||
| } | ||
|
|
||
| // gitCredentialPort returns the canonical port string for credential matching. | ||
| // Ports 80 and 443 (the implicit defaults for http and https) are normalised | ||
| // to the empty string so that http://example.com and https://example.com are | ||
| // considered the same "address" for BasicAuth purposes, while an explicit | ||
| // non-standard port like :8080 is preserved and must match exactly. | ||
| func gitCredentialPort(u *url.URL) string { | ||
| p := u.Port() | ||
| if p == "80" || p == "443" { | ||
| return "" | ||
| } | ||
| return p | ||
| } | ||
|
|
||
| // credentialsFromNetRC reads ~/.netrc and returns credentials for the host in u. | ||
| func credentialsFromNetRC(u *url.URL) transport.AuthMethod { | ||
| home, err := os.UserHomeDir() | ||
| if err != nil { | ||
| return nil | ||
| } | ||
| data, err := os.ReadFile(filepath.Join(home, ".netrc")) | ||
| if err != nil { | ||
| return nil | ||
| } | ||
| login, password := scanNetRC(string(data), u.Hostname()) | ||
| if login == "" && password == "" { | ||
| return nil | ||
| } | ||
| return &githttp.BasicAuth{Username: login, Password: password} | ||
| } | ||
|
|
||
| // scanNetRC is a minimal netrc(5) token scanner. It returns the login and | ||
| // password for the first "machine" stanza whose name matches host. Falls back | ||
| // to the "default" stanza when no exact match is found. | ||
| // | ||
| // Lines beginning with '#' are treated as comments and skipped. This is not | ||
| // part of the official netrc(5) spec but is recognised by curl and Python's | ||
| // netrc module, making the parser more robust in practice. | ||
| func scanNetRC(content, host string) (login, password string) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. btw would it be worth simply adding a ignore case for line starting w So maybe adding that would make this code a bit more robust. just a thought
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
you mean skip lines starting with
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I have handled the problem. scanNetRC skips lines starting with # before tokenising... I have also added a test TestScanNetRC_CommentLinesIgnored to cover it... |
||
| type entry struct{ login, password string } | ||
| var matched, def *entry | ||
| var cur *entry | ||
| inMacdef := false | ||
|
|
||
| // Strip comment lines before tokenising. netrc(5) does not define a comment | ||
| // syntax, but '#'-prefixed lines are a widely-supported de-facto convention. | ||
| var lines []string | ||
| for _, line := range strings.Split(content, "\n") { | ||
| if trimmed := strings.TrimSpace(line); strings.HasPrefix(trimmed, "#") { | ||
| continue | ||
| } | ||
| lines = append(lines, line) | ||
| } | ||
| tokens := strings.Fields(strings.Join(lines, "\n")) | ||
|
|
||
| for i := 0; i < len(tokens); i++ { | ||
| switch tokens[i] { | ||
| case "machine": | ||
| inMacdef = false | ||
| // Per netrc(5): once a match is found, stop at the next stanza. | ||
| if matched != nil { | ||
| return matched.login, matched.password | ||
| } | ||
| if i+1 >= len(tokens) { | ||
| continue | ||
| } | ||
| i++ | ||
| if tokens[i] == host { | ||
| matched = &entry{} | ||
| cur = matched | ||
|
Ankitsinghsisodya marked this conversation as resolved.
|
||
| } else { | ||
| cur = nil | ||
| } | ||
| case "default": | ||
| inMacdef = false | ||
| // Per netrc(5): once a match is found, stop at the next stanza. | ||
| if matched != nil { | ||
| return matched.login, matched.password | ||
| } | ||
| if def == nil { | ||
| def = &entry{} | ||
| } | ||
| cur = def | ||
| case "macdef": | ||
| inMacdef = true | ||
| cur = nil | ||
| i++ // skip macro name | ||
| case "login": | ||
| if inMacdef || i+1 >= len(tokens) { | ||
| continue | ||
| } | ||
| i++ | ||
| if cur != nil { | ||
| cur.login = tokens[i] | ||
| } | ||
| case "password": | ||
| if inMacdef || i+1 >= len(tokens) { | ||
| continue | ||
| } | ||
| i++ | ||
| if cur != nil { | ||
| cur.password = tokens[i] | ||
| } | ||
| } | ||
| } | ||
| if matched != nil { | ||
| return matched.login, matched.password | ||
| } | ||
| if def != nil { | ||
| return def.login, def.password | ||
| } | ||
| return "", "" | ||
| } | ||
|
|
||
| // isAuthError reports whether err indicates that the remote requires | ||
| // authentication (server returned HTTP 401 with no or invalid credentials). | ||
| func isAuthError(err error) bool { | ||
| return err != nil && errors.Is(err, transport.ErrAuthenticationRequired) | ||
| } | ||
|
|
||
| func getGitCloneOptions(uri string) *git.CloneOptions { | ||
| branch := "" | ||
| splitUri := strings.Split(uri, "#") | ||
|
|
@@ -446,8 +641,12 @@ func getGitCloneOptions(uri string) *git.CloneOptions { | |
| branch = splitUri[1] | ||
| } | ||
|
|
||
| opt := &git.CloneOptions{URL: uri, Depth: 1, Tags: git.NoTags, | ||
| RecurseSubmodules: git.NoRecurseSubmodules} | ||
| opt := &git.CloneOptions{ | ||
| URL: uri, | ||
| Depth: 1, | ||
| Tags: git.NoTags, | ||
| RecurseSubmodules: git.NoRecurseSubmodules, | ||
| } | ||
| if branch != "" { | ||
| opt.ReferenceName = plumbing.NewBranchReferenceName(branch) | ||
| } | ||
|
|
@@ -519,8 +718,15 @@ func (r *Repository) Write(dest string) (err error) { | |
| if tempDir, err = os.MkdirTemp("", "func"); err != nil { | ||
| return | ||
| } | ||
| if clone, err = git.PlainClone(tempDir, false, // not bare | ||
| getGitCloneOptions(r.uri)); err != nil { | ||
| cloneOpts := getGitCloneOptions(r.uri) | ||
| clone, err = git.PlainClone(tempDir, false, cloneOpts) // not bare | ||
| if isAuthError(err) { | ||
| if auth := credentialsForURL(cloneOpts.URL); auth != nil { | ||
| cloneOpts.Auth = auth | ||
| clone, err = git.PlainClone(tempDir, false, cloneOpts) | ||
| } | ||
|
Ankitsinghsisodya marked this conversation as resolved.
|
||
| } | ||
| if err != nil { | ||
| return fmt.Errorf("failed to plain clone repository: %w", err) | ||
| } | ||
| if wt, err = clone.Worktree(); err != nil { | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.