Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
224 changes: 215 additions & 9 deletions pkg/functions/repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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)
}
}
Comment thread
Ankitsinghsisodya marked this conversation as resolved.
if err != nil {
if isRepoNotFoundError(err) {
return nil, nil
Expand Down Expand Up @@ -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
Comment thread
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) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 #? AFAIK netrc does not officially support comments but apparently programs like curl do actually mention that its ok to use and they do ignore them. Python has some issues i found python/cpython#104511 but also supports them.

So maybe adding that would make this code a bit more robust. just a thought
cc @matejvasek

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ignore case for line starting w #

you mean skip lines starting with # ?
First time I read that I thought you wanted to turn down case sensitivity 😄
Yes we should skip lines starting with # IMO

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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
Comment thread
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, "#")
Expand All @@ -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)
}
Expand Down Expand Up @@ -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)
}
Comment thread
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 {
Expand Down
Loading
Loading