diff --git a/pkg/functions/repository.go b/pkg/functions/repository.go index 94b2dd03fd..f167188e0e 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,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 + } + 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) { + 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 + } 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) + } + } + 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..93e392056c --- /dev/null +++ b/pkg/functions/repository_credentials_test.go @@ -0,0 +1,297 @@ +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) + } +} + +// 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") + } + }) + } +} + +// 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 { + 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) + } + }) + } +}