400 lines
10 KiB
Go
400 lines
10 KiB
Go
package lfsapi
|
|
|
|
import (
|
|
"bufio"
|
|
"fmt"
|
|
"net/url"
|
|
"os"
|
|
"path"
|
|
"regexp"
|
|
"strings"
|
|
"sync"
|
|
|
|
"github.com/git-lfs/git-lfs/v3/config"
|
|
"github.com/git-lfs/git-lfs/v3/creds"
|
|
"github.com/git-lfs/git-lfs/v3/git"
|
|
"github.com/git-lfs/git-lfs/v3/lfshttp"
|
|
"github.com/git-lfs/git-lfs/v3/tr"
|
|
"github.com/rubyist/tracerx"
|
|
)
|
|
|
|
const (
|
|
defaultRemote = "origin"
|
|
)
|
|
|
|
type EndpointFinder interface {
|
|
NewEndpointFromCloneURL(operation, rawurl string) lfshttp.Endpoint
|
|
NewEndpoint(operation, rawurl string) lfshttp.Endpoint
|
|
Endpoint(operation, remote string) lfshttp.Endpoint
|
|
RemoteEndpoint(operation, remote string) lfshttp.Endpoint
|
|
GitRemoteURL(remote string, forpush bool) string
|
|
AccessFor(rawurl string) creds.Access
|
|
SetAccess(access creds.Access)
|
|
GitProtocol() string
|
|
}
|
|
|
|
type endpointGitFinder struct {
|
|
gitConfig *git.Configuration
|
|
gitEnv config.Environment
|
|
gitProtocol string
|
|
|
|
aliasMu sync.Mutex
|
|
aliases map[string]string
|
|
pushAliases map[string]string
|
|
|
|
accessMu sync.Mutex
|
|
urlAccess map[string]creds.AccessMode
|
|
urlConfig *config.URLConfig
|
|
}
|
|
|
|
func NewEndpointFinder(ctx lfshttp.Context) EndpointFinder {
|
|
if ctx == nil {
|
|
ctx = lfshttp.NewContext(nil, nil, nil)
|
|
}
|
|
|
|
e := &endpointGitFinder{
|
|
gitConfig: ctx.GitConfig(),
|
|
gitEnv: ctx.GitEnv(),
|
|
gitProtocol: "https",
|
|
aliases: make(map[string]string),
|
|
pushAliases: make(map[string]string),
|
|
urlAccess: make(map[string]creds.AccessMode),
|
|
}
|
|
|
|
e.urlConfig = config.NewURLConfig(e.gitEnv)
|
|
if v, ok := e.gitEnv.Get("lfs.gitprotocol"); ok {
|
|
e.gitProtocol = v
|
|
}
|
|
initAliases(e, e.gitEnv)
|
|
|
|
return e
|
|
}
|
|
|
|
func (e *endpointGitFinder) Endpoint(operation, remote string) lfshttp.Endpoint {
|
|
ep := e.getEndpoint(operation, remote)
|
|
ep.Operation = operation
|
|
return ep
|
|
}
|
|
|
|
func (e *endpointGitFinder) getEndpoint(operation, remote string) lfshttp.Endpoint {
|
|
if e.gitEnv == nil {
|
|
return lfshttp.Endpoint{}
|
|
}
|
|
|
|
if operation == "upload" {
|
|
if url, ok := e.gitEnv.Get("lfs.pushurl"); ok {
|
|
return e.NewEndpoint(operation, url)
|
|
}
|
|
}
|
|
|
|
if url, ok := e.gitEnv.Get("lfs.url"); ok {
|
|
return e.NewEndpoint(operation, url)
|
|
}
|
|
|
|
if len(remote) > 0 && remote != defaultRemote {
|
|
if e := e.RemoteEndpoint(operation, remote); len(e.Url) > 0 {
|
|
return e
|
|
}
|
|
}
|
|
|
|
return e.RemoteEndpoint(operation, defaultRemote)
|
|
}
|
|
|
|
func (e *endpointGitFinder) RemoteEndpoint(operation, remote string) lfshttp.Endpoint {
|
|
if e.gitEnv == nil {
|
|
return lfshttp.Endpoint{}
|
|
}
|
|
|
|
if len(remote) == 0 {
|
|
remote = defaultRemote
|
|
}
|
|
|
|
// Support separate push URL if specified and pushing
|
|
if operation == "upload" {
|
|
if url, ok := e.gitEnv.Get("remote." + remote + ".lfspushurl"); ok {
|
|
return e.NewEndpoint(operation, url)
|
|
}
|
|
}
|
|
if url, ok := e.gitEnv.Get("remote." + remote + ".lfsurl"); ok {
|
|
return e.NewEndpoint(operation, url)
|
|
}
|
|
|
|
// fall back on git remote url (also supports pushurl)
|
|
if url := e.GitRemoteURL(remote, operation == "upload"); url != "" {
|
|
return e.NewEndpointFromCloneURL(operation, url)
|
|
}
|
|
|
|
gitDir, err := git.GitDir()
|
|
// Finally, fall back on .git/FETCH_HEAD but only if it exists and no specific remote was requested
|
|
// We can't know which remote FETCH_HEAD is pointing to
|
|
if err == nil && remote == defaultRemote {
|
|
url, err := parseFetchHead(strings.Join([]string{gitDir, "FETCH_HEAD"}, "/"))
|
|
if err == nil {
|
|
endpoint := e.NewEndpointFromCloneURL("download", url)
|
|
return endpoint
|
|
} else {
|
|
tracerx.Printf("failed parsing FETCH_HEAD: %s", err)
|
|
}
|
|
}
|
|
|
|
return lfshttp.Endpoint{}
|
|
}
|
|
|
|
func parseFetchHead(filePath string) (string, error) {
|
|
file, err := os.Open(filePath)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer file.Close()
|
|
|
|
scanner := bufio.NewScanner(file)
|
|
if scanner.Scan() {
|
|
line := scanner.Text()
|
|
return ExtractRemoteUrl(line)
|
|
}
|
|
|
|
return "", fmt.Errorf("Failed to read content from %s", filePath)
|
|
}
|
|
|
|
func ExtractRemoteUrl(line string) (string, error) {
|
|
// see https://regex101.com/r/lYla7c/1
|
|
re := regexp.MustCompile(`^[a-f0-9]{40,64}\t(not-for-merge)?\t(tag |branch |)'.*' of (?P<url>[\/\.\-\:\_a-zA-Z0-9]+)$`)
|
|
|
|
match := re.FindStringSubmatch(line)
|
|
|
|
for i, name := range re.SubexpNames() {
|
|
if name == "url" {
|
|
if len(match) < i {
|
|
break
|
|
}
|
|
return strings.TrimSpace(match[i]), nil
|
|
}
|
|
}
|
|
|
|
return "", fmt.Errorf("failed to extract remote URL from \"%s\"", line)
|
|
}
|
|
|
|
func (e *endpointGitFinder) GitRemoteURL(remote string, forpush bool) string {
|
|
if e.gitEnv != nil {
|
|
if forpush {
|
|
if u, ok := e.gitEnv.Get("remote." + remote + ".pushurl"); ok {
|
|
return u
|
|
}
|
|
}
|
|
|
|
if u, ok := e.gitEnv.Get("remote." + remote + ".url"); ok {
|
|
return u
|
|
}
|
|
}
|
|
|
|
if err := git.ValidateRemote(remote); err == nil {
|
|
return remote
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
func (e *endpointGitFinder) NewEndpointFromCloneURL(operation, rawurl string) lfshttp.Endpoint {
|
|
ep := e.NewEndpoint(operation, rawurl)
|
|
if ep.Url == lfshttp.UrlUnknown {
|
|
return ep
|
|
}
|
|
|
|
if strings.HasSuffix(ep.Url, "/") {
|
|
ep.Url = ep.Url[0 : len(ep.Url)-1]
|
|
}
|
|
|
|
if strings.HasPrefix(ep.Url, "file://") {
|
|
return ep
|
|
}
|
|
|
|
// When using main remote URL for HTTP, append info/lfs
|
|
if path.Ext(ep.Url) == ".git" {
|
|
ep.Url += "/info/lfs"
|
|
} else {
|
|
ep.Url += ".git/info/lfs"
|
|
}
|
|
|
|
return ep
|
|
}
|
|
|
|
func (e *endpointGitFinder) NewEndpoint(operation, rawurl string) lfshttp.Endpoint {
|
|
rawurl = e.ReplaceUrlAlias(operation, rawurl)
|
|
if strings.HasPrefix(rawurl, "/") {
|
|
return lfshttp.EndpointFromLocalPath(rawurl)
|
|
}
|
|
u, err := url.Parse(rawurl)
|
|
if err != nil {
|
|
return lfshttp.EndpointFromBareSshUrl(rawurl)
|
|
}
|
|
|
|
switch u.Scheme {
|
|
case "ssh", "git+ssh", "ssh+git":
|
|
return lfshttp.EndpointFromSshUrl(u)
|
|
case "http", "https":
|
|
return lfshttp.EndpointFromHttpUrl(u)
|
|
case "git":
|
|
return endpointFromGitUrl(u, e)
|
|
case "file":
|
|
return lfshttp.EndpointFromFileUrl(u)
|
|
case "":
|
|
// If it looks like a local path, it probably is.
|
|
if _, err := os.Stat(rawurl); err == nil {
|
|
return lfshttp.EndpointFromLocalPath(rawurl)
|
|
}
|
|
return lfshttp.EndpointFromBareSshUrl(u.String())
|
|
default:
|
|
if strings.HasPrefix(rawurl, u.Scheme+"::") {
|
|
// Looks like a remote helper; just pass it through.
|
|
return lfshttp.Endpoint{Url: rawurl}
|
|
}
|
|
// If it looks like a local path, it probably is.
|
|
if _, err := os.Stat(rawurl); err == nil {
|
|
return lfshttp.EndpointFromLocalPath(rawurl)
|
|
}
|
|
// We probably got here because the "scheme" that was parsed is
|
|
// a hostname (whether FQDN or single word) and the URL parser
|
|
// didn't know what to do with it. Do what Git does and treat
|
|
// it as an SSH URL. This ensures we handle SSH config aliases
|
|
// properly.
|
|
return lfshttp.EndpointFromBareSshUrl(u.String())
|
|
}
|
|
}
|
|
|
|
func (e *endpointGitFinder) AccessFor(rawurl string) creds.Access {
|
|
accessurl := urlWithoutAuth(rawurl)
|
|
|
|
if e.gitEnv == nil {
|
|
return creds.NewAccess(creds.NoneAccess, accessurl)
|
|
}
|
|
|
|
e.accessMu.Lock()
|
|
defer e.accessMu.Unlock()
|
|
|
|
if cached, ok := e.urlAccess[accessurl]; ok {
|
|
return creds.NewAccess(cached, accessurl)
|
|
}
|
|
|
|
e.urlAccess[accessurl] = e.fetchGitAccess(accessurl)
|
|
return creds.NewAccess(e.urlAccess[accessurl], accessurl)
|
|
}
|
|
|
|
func (e *endpointGitFinder) SetAccess(access creds.Access) {
|
|
key := fmt.Sprintf("lfs.%s.access", access.URL())
|
|
tracerx.Printf("setting repository access to %s", access.Mode())
|
|
|
|
e.accessMu.Lock()
|
|
defer e.accessMu.Unlock()
|
|
|
|
switch access.Mode() {
|
|
case creds.EmptyAccess, creds.NoneAccess:
|
|
e.gitConfig.UnsetLocalKey(key)
|
|
e.urlAccess[access.URL()] = creds.NoneAccess
|
|
default:
|
|
e.gitConfig.SetLocal(key, string(access.Mode()))
|
|
e.urlAccess[access.URL()] = access.Mode()
|
|
}
|
|
}
|
|
|
|
func urlWithoutAuth(rawurl string) string {
|
|
if !strings.Contains(rawurl, "@") {
|
|
return rawurl
|
|
}
|
|
|
|
u, err := url.Parse(rawurl)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error parsing URL %q: %s", rawurl, err)
|
|
return rawurl
|
|
}
|
|
|
|
u.User = nil
|
|
return u.String()
|
|
}
|
|
|
|
func (e *endpointGitFinder) fetchGitAccess(rawurl string) creds.AccessMode {
|
|
if v, _ := e.urlConfig.Get("lfs", rawurl, "access"); len(v) > 0 {
|
|
access := creds.AccessMode(strings.ToLower(v))
|
|
if access == creds.PrivateAccess {
|
|
return creds.BasicAccess
|
|
}
|
|
return access
|
|
}
|
|
return creds.NoneAccess
|
|
}
|
|
|
|
func (e *endpointGitFinder) GitProtocol() string {
|
|
return e.gitProtocol
|
|
}
|
|
|
|
// ReplaceUrlAlias returns a url with a prefix from a `url.*.insteadof` git
|
|
// config setting. If multiple aliases match, use the longest one.
|
|
// See https://git-scm.com/docs/git-config for Git's docs.
|
|
func (e *endpointGitFinder) ReplaceUrlAlias(operation, rawurl string) string {
|
|
e.aliasMu.Lock()
|
|
defer e.aliasMu.Unlock()
|
|
|
|
if operation == "upload" {
|
|
if rawurl, replaced := e.replaceUrlAlias(e.pushAliases, rawurl); replaced {
|
|
return rawurl
|
|
}
|
|
}
|
|
rawurl, _ = e.replaceUrlAlias(e.aliases, rawurl)
|
|
|
|
return rawurl
|
|
}
|
|
|
|
// replaceUrlAlias is a helper function for ReplaceUrlAlias. It must only be
|
|
// called while the e.aliasMu mutex is held.
|
|
func (e *endpointGitFinder) replaceUrlAlias(aliases map[string]string, rawurl string) (string, bool) {
|
|
var longestalias string
|
|
for alias, _ := range aliases {
|
|
if !strings.HasPrefix(rawurl, alias) {
|
|
continue
|
|
}
|
|
|
|
if longestalias < alias {
|
|
longestalias = alias
|
|
}
|
|
}
|
|
|
|
if len(longestalias) > 0 {
|
|
return aliases[longestalias] + rawurl[len(longestalias):], true
|
|
}
|
|
|
|
return rawurl, false
|
|
}
|
|
|
|
const (
|
|
aliasPrefix = "url."
|
|
)
|
|
|
|
func initAliases(e *endpointGitFinder, git config.Environment) {
|
|
suffix := ".insteadof"
|
|
pushSuffix := ".pushinsteadof"
|
|
for gitkey, gitval := range git.All() {
|
|
if len(gitval) == 0 || !strings.HasPrefix(gitkey, aliasPrefix) {
|
|
continue
|
|
}
|
|
if strings.HasSuffix(gitkey, suffix) {
|
|
storeAlias(e.aliases, gitkey, gitval, suffix)
|
|
} else if strings.HasSuffix(gitkey, pushSuffix) {
|
|
storeAlias(e.pushAliases, gitkey, gitval, pushSuffix)
|
|
}
|
|
}
|
|
}
|
|
|
|
func storeAlias(aliases map[string]string, key string, values []string, suffix string) {
|
|
for _, value := range values {
|
|
url := key[len(aliasPrefix) : len(key)-len(suffix)]
|
|
if v, ok := aliases[value]; ok && v != url {
|
|
fmt.Fprintln(os.Stderr, tr.Tr.Get("warning: Multiple 'url.*.%s' keys with the same alias: %q", suffix, value))
|
|
}
|
|
aliases[value] = url
|
|
}
|
|
}
|
|
|
|
func endpointFromGitUrl(u *url.URL, e *endpointGitFinder) lfshttp.Endpoint {
|
|
u.Scheme = e.gitProtocol
|
|
return lfshttp.Endpoint{Url: u.String()}
|
|
}
|