From d71e10fcbf5233b0183cbcb72f7037f26430e13f Mon Sep 17 00:00:00 2001 From: Ankitsinghsisodya Date: Thu, 23 Apr 2026 02:27:48 +0530 Subject: [PATCH 1/3] fix: resolve credentials for protected HTTP(S) repositories When --repository points to a protected HTTP(S) remote, go-git's pure-Go clone had no access to the system credential stack, causing authentication failures even when the user had credentials configured via ~/.git-credentials or ~/.netrc. Adds credentialsForURL which reads credentials from, in order: 1. ~/.git-credentials (git credential store helper format) 2. ~/.netrc The retry follows git's own challenge/response model: an anonymous clone is attempted first; credentialsForURL is only called when the server responds with HTTP 401 (transport.ErrAuthenticationRequired). This avoids sending credentials to servers that do not require them. No subprocesses are spawned and no git binary on PATH is required. Fixes #3415 Signed-off-by: Ankitsinghsisodya --- pkg/functions/repository.go | 173 ++++++++++++++++++- pkg/functions/repository_credentials_test.go | 158 +++++++++++++++++ 2 files changed, 322 insertions(+), 9 deletions(-) create mode 100644 pkg/functions/repository_credentials_test.go diff --git a/pkg/functions/repository.go b/pkg/functions/repository.go index 94b2dd03fd..2a94fcf8d9 100644 --- a/pkg/functions/repository.go +++ b/pkg/functions/repository.go @@ -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,145 @@ 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 +// +// The first line whose scheme and hostname match u 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 + } + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + entry, err := url.Parse(line) + if err != nil || entry.Scheme != u.Scheme || entry.Hostname() != u.Hostname() { + continue + } + 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 +} + +// 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. +func scanNetRC(content, host string) (login, password string) { + type entry struct{ login, password string } + var matched, def *entry + var cur *entry + inMacdef := false + + tokens := strings.Fields(content) + for i := 0; i < len(tokens); i++ { + switch tokens[i] { + case "machine": + inMacdef = false + if i+1 >= len(tokens) { + continue + } + i++ + if tokens[i] == host { + matched = &entry{} + cur = matched + } else { + cur = nil + } + case "default": + inMacdef = false + 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 +590,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 +667,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) + } + } + if err != nil { return fmt.Errorf("failed to plain clone repository: %w", err) } if wt, err = clone.Worktree(); err != nil { diff --git a/pkg/functions/repository_credentials_test.go b/pkg/functions/repository_credentials_test.go new file mode 100644 index 0000000000..d072a3db8c --- /dev/null +++ b/pkg/functions/repository_credentials_test.go @@ -0,0 +1,158 @@ +package functions + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/go-git/go-git/v5/plumbing/transport" + githttp "github.com/go-git/go-git/v5/plumbing/transport/http" +) + +// setupGitCredentialStore writes a temp ~/.git-credentials file and points +// HOME at the temp directory so os.UserHomeDir() returns it during the test. +func setupGitCredentialStore(t *testing.T, credLines ...string) { + t.Helper() + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) // Windows + + var creds string + for _, line := range credLines { + creds += line + "\n" + } + if err := os.WriteFile(filepath.Join(home, ".git-credentials"), []byte(creds), 0600); err != nil { + t.Fatal(err) + } +} + +// TestCredentialsForURL_HTTPS verifies that credentials stored in +// ~/.git-credentials are returned for a matching HTTPS URL. +func TestCredentialsForURL_HTTPS(t *testing.T) { + setupGitCredentialStore(t, "https://alice:s3cr3t@example.com") + + auth := credentialsForURL("https://example.com/org/repo") + if auth == nil { + t.Fatal("expected non-nil AuthMethod, got nil") + } + basic, ok := auth.(*githttp.BasicAuth) + if !ok { + t.Fatalf("expected *githttp.BasicAuth, got %T", auth) + } + if basic.Username != "alice" { + t.Errorf("username: want %q, got %q", "alice", basic.Username) + } + if basic.Password != "s3cr3t" { + t.Errorf("password: want %q, got %q", "s3cr3t", basic.Password) + } +} + +// TestCredentialsForURL_TokenAuth verifies that a token stored with an empty +// username (common for GitHub/GitLab PATs via x-oauth-basic) is accepted. +func TestCredentialsForURL_TokenAuth(t *testing.T) { + setupGitCredentialStore(t, "https://x-oauth-basic:ghp_tok3n@github.com") + + auth := credentialsForURL("https://github.com/org/repo") + if auth == nil { + t.Fatal("expected non-nil AuthMethod, got nil") + } + basic, ok := auth.(*githttp.BasicAuth) + if !ok { + t.Fatalf("expected *githttp.BasicAuth, got %T", auth) + } + if basic.Password != "ghp_tok3n" { + t.Errorf("password: want %q, got %q", "ghp_tok3n", basic.Password) + } +} + +// TestCredentialsForURL_NoMatchingEntry verifies that nil is returned when +// ~/.git-credentials has no entry for the requested host. +func TestCredentialsForURL_NoMatchingEntry(t *testing.T) { + setupGitCredentialStore(t, "https://user:pass@other.com") + + auth := credentialsForURL("https://example.com/repo") + if auth != nil { + t.Fatalf("expected nil AuthMethod for unmatched host, got %v", auth) + } +} + +// TestCredentialsForURL_NonHTTP verifies that nil is returned immediately for +// non-HTTP(S) schemes without reading any credential file. +func TestCredentialsForURL_NonHTTP(t *testing.T) { + setupGitCredentialStore(t, "https://user:pass@example.com") + + for _, u := range []string{ + "git@example.com:org/repo.git", + "ssh://git@example.com/repo", + "file:///local/repo", + } { + if auth := credentialsForURL(u); auth != nil { + t.Errorf("credentialsForURL(%q): expected nil for non-HTTP(S) scheme, got %v", u, auth) + } + } +} + +// TestCredentialsForURL_NoCredentialsFile verifies that nil is returned when +// neither ~/.git-credentials nor ~/.netrc exist. +func TestCredentialsForURL_NoCredentialsFile(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + + auth := credentialsForURL("https://example.com/repo") + if auth != nil { + t.Fatalf("expected nil AuthMethod when no credentials files exist, got %v", auth) + } +} + +// TestCredentialsForURL_NetRC verifies fallback to ~/.netrc when +// ~/.git-credentials has no match. +func TestCredentialsForURL_NetRC(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + + netrc := "machine example.com login bob password secret\n" + if err := os.WriteFile(filepath.Join(home, ".netrc"), []byte(netrc), 0600); err != nil { + t.Fatal(err) + } + + auth := credentialsForURL("https://example.com/repo") + if auth == nil { + t.Fatal("expected non-nil AuthMethod from .netrc, got nil") + } + basic, ok := auth.(*githttp.BasicAuth) + if !ok { + t.Fatalf("expected *githttp.BasicAuth, got %T", auth) + } + if basic.Username != "bob" { + t.Errorf("username: want %q, got %q", "bob", basic.Username) + } + if basic.Password != "secret" { + t.Errorf("password: want %q, got %q", "secret", basic.Password) + } +} + +// TestIsAuthError verifies the sentinel value detection used for retry logic. +func TestIsAuthError(t *testing.T) { + tests := []struct { + name string + err error + want bool + }{ + {"nil", nil, false}, + {"unrelated error", errors.New("repository not found"), false}, + {"ErrAuthenticationRequired", transport.ErrAuthenticationRequired, true}, + {"wrapped ErrAuthenticationRequired", fmt.Errorf("clone failed: %w", transport.ErrAuthenticationRequired), true}, + {"ErrAuthorizationFailed is not auth-required", transport.ErrAuthorizationFailed, false}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := isAuthError(tc.err); got != tc.want { + t.Errorf("isAuthError(%v) = %v, want %v", tc.err, got, tc.want) + } + }) + } +} From ccff2982ebeebb3f018abbd42982e2001a3cb7e2 Mon Sep 17 00:00:00 2001 From: Ankitsinghsisodya Date: Wed, 6 May 2026 11:19:03 +0530 Subject: [PATCH 2/3] test: add cross-scheme and port matching tests for credentials Introduces new tests to verify credential matching behavior for different URL schemes and port configurations. The tests ensure that: - Credentials stored under `http://` are returned for `https://` requests and vice-versa. - Credentials with explicit non-standard ports do not match URLs on different ports. - Credentials with explicit ports match requests with the same port. - Implicit default ports (80 for http, 443 for https) are treated as equivalent to no port. These changes enhance the robustness of the credential handling logic in the repository. --- pkg/functions/repository.go | 33 +++++- pkg/functions/repository_credentials_test.go | 106 +++++++++++++++++++ 2 files changed, 137 insertions(+), 2 deletions(-) diff --git a/pkg/functions/repository.go b/pkg/functions/repository.go index 2a94fcf8d9..283aeda50f 100644 --- a/pkg/functions/repository.go +++ b/pkg/functions/repository.go @@ -466,7 +466,16 @@ func credentialsForURL(rawURL string) transport.AuthMethod { // // https://user:password@host // -// The first line whose scheme and hostname match u wins. +// 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 { @@ -476,13 +485,20 @@ func credentialsFromGitStore(u *url.URL) transport.AuthMethod { 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 || entry.Scheme != u.Scheme || entry.Hostname() != u.Hostname() { + if err != nil { + continue + } + if entry.Hostname() != u.Hostname() { + continue + } + if gitCredentialPort(entry) != targetPort { continue } if entry.User == nil { @@ -498,6 +514,19 @@ func credentialsFromGitStore(u *url.URL) transport.AuthMethod { 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() diff --git a/pkg/functions/repository_credentials_test.go b/pkg/functions/repository_credentials_test.go index d072a3db8c..82f1604b70 100644 --- a/pkg/functions/repository_credentials_test.go +++ b/pkg/functions/repository_credentials_test.go @@ -135,6 +135,112 @@ func TestCredentialsForURL_NetRC(t *testing.T) { } } +// TestCredentialsForURL_CrossScheme verifies that a credential stored under +// http:// is returned when the request URL uses https://, and vice-versa. +// This mirrors git's own behaviour: scheme is not part of the host identity +// for BasicAuth purposes. +func TestCredentialsForURL_CrossScheme(t *testing.T) { + tests := []struct { + name string + storedURL string + requestURL string + }{ + { + name: "http credential matches https request", + storedURL: "http://alice:s3cr3t@example.com", + requestURL: "https://example.com/org/repo", + }, + { + name: "https credential matches http request", + storedURL: "https://alice:s3cr3t@example.com", + requestURL: "http://example.com/org/repo", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + setupGitCredentialStore(t, tc.storedURL) + auth := credentialsForURL(tc.requestURL) + if auth == nil { + t.Fatalf("expected non-nil AuthMethod for cross-scheme match, got nil") + } + basic, ok := auth.(*githttp.BasicAuth) + if !ok { + t.Fatalf("expected *githttp.BasicAuth, got %T", auth) + } + if basic.Username != "alice" { + t.Errorf("username: want %q, got %q", "alice", basic.Username) + } + }) + } +} + +// TestCredentialsForURL_PortMismatch verifies that a credential with an +// explicit non-standard port does NOT match a URL on a different port. +// http://example.com:8080 must not satisfy a request to http://example.com. +func TestCredentialsForURL_PortMismatch(t *testing.T) { + setupGitCredentialStore(t, "http://alice:s3cr3t@example.com:8080") + + auth := credentialsForURL("http://example.com/repo") + if auth != nil { + t.Fatalf("expected nil AuthMethod for port mismatch, got %v", auth) + } +} + +// TestCredentialsForURL_PortMatch verifies that a credential with an explicit +// non-standard port matches a request URL with the same port. +func TestCredentialsForURL_PortMatch(t *testing.T) { + setupGitCredentialStore(t, "https://alice:s3cr3t@example.com:8443") + + auth := credentialsForURL("https://example.com:8443/repo") + if auth == nil { + t.Fatal("expected non-nil AuthMethod for matching custom port, got nil") + } + basic, ok := auth.(*githttp.BasicAuth) + if !ok { + t.Fatalf("expected *githttp.BasicAuth, got %T", auth) + } + if basic.Username != "alice" { + t.Errorf("username: want %q, got %q", "alice", basic.Username) + } +} + +// TestCredentialsForURL_ImplicitPortsAreEquivalent verifies that the implicit +// default ports (80 for http, 443 for https) are treated as equivalent to no +// port at all, so http://example.com and https://example.com:443 match the +// same credential entry. +func TestCredentialsForURL_ImplicitPortsAreEquivalent(t *testing.T) { + tests := []struct { + name string + storedURL string + requestURL string + }{ + { + name: "stored port 443 matches https no-port request", + storedURL: "https://alice:s3cr3t@example.com:443", + requestURL: "https://example.com/repo", + }, + { + name: "stored port 80 matches http no-port request", + storedURL: "http://alice:s3cr3t@example.com:80", + requestURL: "http://example.com/repo", + }, + { + name: "stored no-port matches request with explicit port 443", + storedURL: "https://alice:s3cr3t@example.com", + requestURL: "https://example.com:443/repo", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + setupGitCredentialStore(t, tc.storedURL) + auth := credentialsForURL(tc.requestURL) + if auth == nil { + t.Fatalf("expected non-nil AuthMethod for implicit-port equivalence, got nil") + } + }) + } +} + // TestIsAuthError verifies the sentinel value detection used for retry logic. func TestIsAuthError(t *testing.T) { tests := []struct { From dd8c16e29e2213db9d6a9f7af9364e886c03852f Mon Sep 17 00:00:00 2001 From: Ankitsinghsisodya Date: Thu, 7 May 2026 16:45:30 +0530 Subject: [PATCH 3/3] test: add unit tests for scanNetRC function Introduces new tests for the scanNetRC function to verify its behavior when parsing .netrc files. The tests ensure that: - The first matching machine stanza is returned when multiple stanzas match the same host. - Lines beginning with '#' are correctly ignored as comments during parsing. These additions enhance the reliability of the credential retrieval process from .netrc files. --- pkg/functions/repository.go | 24 +++++++++++++- pkg/functions/repository_credentials_test.go | 33 ++++++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/pkg/functions/repository.go b/pkg/functions/repository.go index 283aeda50f..f167188e0e 100644 --- a/pkg/functions/repository.go +++ b/pkg/functions/repository.go @@ -547,17 +547,35 @@ func credentialsFromNetRC(u *url.URL) transport.AuthMethod { // 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) { type entry struct{ login, password string } var matched, def *entry var cur *entry inMacdef := false - tokens := strings.Fields(content) + // 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 } @@ -570,6 +588,10 @@ func scanNetRC(content, host string) (login, password string) { } 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{} } diff --git a/pkg/functions/repository_credentials_test.go b/pkg/functions/repository_credentials_test.go index 82f1604b70..93e392056c 100644 --- a/pkg/functions/repository_credentials_test.go +++ b/pkg/functions/repository_credentials_test.go @@ -241,6 +241,39 @@ func TestCredentialsForURL_ImplicitPortsAreEquivalent(t *testing.T) { } } +// TestScanNetRC_FirstMatchWins verifies that when multiple machine stanzas +// match the same host, the first one is returned (per netrc(5) spec). +func TestScanNetRC_FirstMatchWins(t *testing.T) { + content := ` +machine example.com login first password one +machine example.com login second password two +` + login, password := scanNetRC(content, "example.com") + if login != "first" { + t.Errorf("login: want %q, got %q", "first", login) + } + if password != "one" { + t.Errorf("password: want %q, got %q", "one", password) + } +} + +// TestScanNetRC_CommentLinesIgnored verifies that lines beginning with '#' +// are treated as comments and do not interfere with parsing. +func TestScanNetRC_CommentLinesIgnored(t *testing.T) { + content := ` +# this is a comment +machine example.com login alice password secret +# another comment +` + login, password := scanNetRC(content, "example.com") + if login != "alice" { + t.Errorf("login: want %q, got %q", "alice", login) + } + if password != "secret" { + t.Errorf("password: want %q, got %q", "secret", password) + } +} + // TestIsAuthError verifies the sentinel value detection used for retry logic. func TestIsAuthError(t *testing.T) { tests := []struct {