9b99b7d1fd
Co-authored-by: ayattara <alassane.yattara@savoirfairelinux.com> Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
171 lines
6.3 KiB
Go
171 lines
6.3 KiB
Go
package gitcredentials
|
|
|
|
import (
|
|
"fmt"
|
|
"net/url"
|
|
"strings"
|
|
|
|
"github.com/kandev/kandev/internal/repoclone"
|
|
)
|
|
|
|
const publicGitHubHost = "github.com"
|
|
|
|
// RepositoryIdentityInput is the non-secret persisted repository metadata used
|
|
// to authorize a managed Git credential scope. Provider remains routing
|
|
// metadata; ProviderHost alone establishes the authorized HTTPS origin.
|
|
type RepositoryIdentityInput struct {
|
|
RepositoryID string
|
|
Provider string
|
|
ProviderHost string
|
|
ProviderOwner string
|
|
ProviderName string
|
|
RemoteURL string
|
|
}
|
|
|
|
// RepositoryIdentity is the canonical HTTPS identity used by issuers and the
|
|
// credential broker.
|
|
type RepositoryIdentity struct {
|
|
Host string
|
|
Path string
|
|
Owner string
|
|
Repository string
|
|
}
|
|
|
|
func ResolveRepositoryIdentity(input RepositoryIdentityInput) (RepositoryIdentity, error) {
|
|
remoteURL := strings.TrimSpace(input.RemoteURL)
|
|
if remoteURL == "" {
|
|
if input.ProviderOwner == "" || input.ProviderName == "" {
|
|
return RepositoryIdentity{}, repositoryIdentityError(input.RepositoryID, "has no clone URL")
|
|
}
|
|
origin, err := trustedOrigin(input, "")
|
|
if err != nil {
|
|
return RepositoryIdentity{}, err
|
|
}
|
|
remoteURL = origin + "/" + strings.Trim(input.ProviderOwner, "/") + "/" + strings.Trim(input.ProviderName, "/") + ".git"
|
|
}
|
|
|
|
remote, err := parseRemote(remoteURL)
|
|
if err != nil {
|
|
return RepositoryIdentity{}, repositoryIdentityError(input.RepositoryID, "must use a credential-free HTTPS or SSH clone URL")
|
|
}
|
|
return resolveParsedRepositoryIdentity(input, remote)
|
|
}
|
|
|
|
func resolveParsedRepositoryIdentity(input RepositoryIdentityInput, remote parsedRemote) (RepositoryIdentity, error) {
|
|
origin, err := trustedOrigin(input, remote.hostname)
|
|
if err != nil {
|
|
return RepositoryIdentity{}, err
|
|
}
|
|
originURL, _ := url.Parse(origin)
|
|
if remote.isHTTPS {
|
|
if err := repoclone.ValidateHTTPSCloneOrigin(remote.canonicalURL, origin); err != nil {
|
|
return RepositoryIdentity{}, repositoryIdentityError(input.RepositoryID, "remote host conflicts with its provider origin")
|
|
}
|
|
} else if !strings.EqualFold(remote.hostname, originURL.Hostname()) {
|
|
return RepositoryIdentity{}, repositoryIdentityError(input.RepositoryID, "remote host conflicts with its provider origin")
|
|
}
|
|
|
|
canonicalPath, err := canonicalIdentityPath(input, remote.path)
|
|
if err != nil {
|
|
return RepositoryIdentity{}, err
|
|
}
|
|
owner, repository, err := identityPath(canonicalPath)
|
|
if err != nil {
|
|
return RepositoryIdentity{}, repositoryIdentityError(input.RepositoryID, "has an invalid clone path")
|
|
}
|
|
return RepositoryIdentity{Host: originURL.Host, Path: canonicalPath, Owner: owner, Repository: repository}, nil
|
|
}
|
|
|
|
func canonicalIdentityPath(input RepositoryIdentityInput, remotePath string) (string, error) {
|
|
if !strings.EqualFold(strings.TrimSpace(input.Provider), "github") || input.ProviderOwner == "" || input.ProviderName == "" {
|
|
return remotePath, nil
|
|
}
|
|
remoteOwner, remoteRepository, err := identityPath(remotePath)
|
|
if err != nil || !strings.EqualFold(remoteOwner, strings.TrimSpace(input.ProviderOwner)) ||
|
|
!strings.EqualFold(strings.TrimSuffix(remoteRepository, ".git"), strings.TrimSuffix(strings.TrimSpace(input.ProviderName), ".git")) {
|
|
return "", repositoryIdentityError(input.RepositoryID, "remote path conflicts with its provider identity")
|
|
}
|
|
return "/" + strings.Trim(input.ProviderOwner, "/") + "/" + strings.Trim(input.ProviderName, "/") + ".git", nil
|
|
}
|
|
|
|
type parsedRemote struct {
|
|
hostname string
|
|
path string
|
|
canonicalURL string
|
|
isHTTPS bool
|
|
}
|
|
|
|
func parseRemote(raw string) (parsedRemote, error) {
|
|
value := strings.TrimSpace(raw)
|
|
canonical := repoclone.CanonicalHTTPSCloneURL(value)
|
|
if canonical != "" {
|
|
if err := validateSSHRemote(value); err != nil {
|
|
return parsedRemote{}, err
|
|
}
|
|
parsed, err := url.Parse(canonical)
|
|
if err != nil || parsed.Host == "" || parsed.Path == "" {
|
|
return parsedRemote{}, fmt.Errorf("invalid SSH clone URL")
|
|
}
|
|
return parsedRemote{hostname: parsed.Hostname(), path: parsed.Path, canonicalURL: canonical}, nil
|
|
}
|
|
parsed, err := url.Parse(value)
|
|
if err != nil || !strings.EqualFold(parsed.Scheme, "https") || parsed.Host == "" || parsed.User != nil ||
|
|
parsed.RawQuery != "" || parsed.ForceQuery || parsed.Fragment != "" || parsed.Path == "" {
|
|
return parsedRemote{}, fmt.Errorf("invalid HTTPS clone URL")
|
|
}
|
|
return parsedRemote{hostname: parsed.Hostname(), path: parsed.Path, canonicalURL: "https://" + strings.ToLower(parsed.Host) + parsed.Path, isHTTPS: true}, nil
|
|
}
|
|
|
|
func validateSSHRemote(value string) error {
|
|
if !strings.Contains(value, "://") {
|
|
if strings.ContainsAny(value, "?#") {
|
|
return fmt.Errorf("invalid SSH clone URL")
|
|
}
|
|
return nil
|
|
}
|
|
parsed, err := url.Parse(value)
|
|
if err != nil || !strings.EqualFold(parsed.Scheme, "ssh") || parsed.Hostname() == "" ||
|
|
parsed.Path == "" || parsed.Path == "/" || parsed.RawQuery != "" || parsed.ForceQuery || parsed.Fragment != "" {
|
|
return fmt.Errorf("invalid SSH clone URL")
|
|
}
|
|
if parsed.User != nil {
|
|
if _, hasPassword := parsed.User.Password(); hasPassword {
|
|
return fmt.Errorf("invalid SSH clone URL")
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func trustedOrigin(input RepositoryIdentityInput, remoteHost string) (string, error) {
|
|
if host := strings.TrimSpace(input.ProviderHost); host != "" {
|
|
origin, err := repoclone.HTTPSProviderOrigin(host)
|
|
if err != nil {
|
|
return "", repositoryIdentityError(input.RepositoryID, "has an invalid provider host")
|
|
}
|
|
return origin, nil
|
|
}
|
|
if strings.EqualFold(remoteHost, publicGitHubHost) {
|
|
return "https://" + publicGitHubHost, nil
|
|
}
|
|
if strings.EqualFold(strings.TrimSpace(input.Provider), "github") && remoteHost == "" {
|
|
return "https://" + publicGitHubHost, nil
|
|
}
|
|
return "", repositoryIdentityError(input.RepositoryID, "needs a provider host for managed credentials")
|
|
}
|
|
|
|
func identityPath(path string) (string, string, error) {
|
|
trimmed := strings.TrimSuffix(strings.Trim(path, "/"), ".git")
|
|
owner, repository, found := strings.Cut(trimmed, "/")
|
|
if !found || owner == "" || repository == "" {
|
|
return "", "", fmt.Errorf("path needs owner and repository")
|
|
}
|
|
return owner, repository, nil
|
|
}
|
|
|
|
func repositoryIdentityError(repositoryID, detail string) error {
|
|
if strings.TrimSpace(repositoryID) == "" {
|
|
return fmt.Errorf("repository identity %s", detail)
|
|
}
|
|
return fmt.Errorf("repository %q %s; repair its clone URL or provider host", repositoryID, detail)
|
|
}
|