Files
gitea-tea/internal/settings/remote.go
T

243 lines
8.6 KiB
Go

// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package settings
import (
"errors"
"fmt"
"net/url"
"strings"
)
// RemoteIsSSH reports whether a Git remote uses SSH: an ssh:// URL or an scp-style user@host:path.
func RemoteIsSSH(remote string) bool {
return strings.HasPrefix(remote, "ssh://") || scpLike(remote)
}
func scpLike(remote string) bool {
return !strings.Contains(remote, "://") && strings.Contains(remote, "@")
}
// ParseRemote parses a Git remote URL, reading scp-style user@host:path as ssh://user@host/path
// without interpreting shell syntax.
func ParseRemote(raw string) (*url.URL, error) {
if user, path, ok := strings.Cut(raw, ":"); ok && scpLike(raw) {
raw = "ssh://" + user + "/" + path
}
return url.Parse(raw)
}
// RepositoryURL resolves a repository URL or Git remote to a configured instance and its owner/name.
// Unconfigured HTTP(S) URLs take the final two path segments as the repository.
func RepositoryURL(raw string, hosts map[string]Host) (string, string, error) {
u, err := ParseRemote(raw)
if err != nil || u.Host == "" || u.RawQuery != "" || u.Fragment != "" || (u.Scheme != "https" && u.Scheme != "http" && u.Scheme != "ssh") {
return "", "", errors.New("invalid repository URL")
}
password := false
if u.User != nil {
_, password = u.User.Password()
}
if u.User != nil && (u.Scheme != "ssh" || password) {
return "", "", errors.New("repository URLs must not embed credentials")
}
slug, cloneURL := strings.CutSuffix(strings.Trim(u.Path, "/"), ".git")
bestHost, bestRepo, bestLen := "", "", -1
matchedHost := false
remoteSSH, remoteErr := SSHEndpoint(u.Host)
for instance, host := range hosts {
base, _ := url.Parse(instance)
sshHost := host.SSHHost
if sshHost == "" {
sshHost = base.Hostname()
if strings.Contains(sshHost, ":") {
sshHost = "[" + sshHost + "]"
}
}
prefix := strings.Trim(base.Path, "/")
configuredSSH, configuredErr := SSHEndpoint(sshHost)
match := (u.Scheme == "ssh" && remoteErr == nil && configuredErr == nil && remoteSSH == configuredSSH) || (u.Scheme == base.Scheme && strings.EqualFold(u.Host, base.Host))
if !match {
continue
}
matchedHost = true
candidate := slug
if u.Scheme != "ssh" && prefix != "" {
if !strings.HasPrefix(slug, prefix+"/") {
continue
}
candidate = strings.TrimPrefix(slug, prefix+"/")
}
if !ValidSlug(candidate) {
continue
}
if bestHost != "" && u.Scheme == "ssh" && bestHost != instance {
return "", "", errors.New("SSH remote matches multiple instances; select --host")
}
if len(prefix) == bestLen && bestHost != instance {
return "", "", errors.New("repository URL matches multiple instances; select --host")
}
if len(prefix) > bestLen {
bestHost, bestRepo, bestLen = instance, candidate, len(prefix)
}
}
if bestHost != "" {
return bestHost, bestRepo, nil
}
if u.Scheme == "ssh" {
return "", "", errors.New("SSH remote does not match a configured instance")
}
if matchedHost {
return "", "", errors.New("repository URL path is ambiguous; use owner/repository or a clone URL")
}
parts := strings.Split(slug, "/")
if len(parts) < 2 {
return "", "", errors.New("repository URL requires owner/repository")
}
repo := strings.Join(parts[len(parts)-2:], "/")
// A .git suffix marks a clone URL, whose repository may share its name with a web route.
if !ValidSlug(repo) || (!cloneURL && looksLikeWebResourcePath(parts)) {
return "", "", errors.New("repository URL path is ambiguous; use owner/repository or a clone URL")
}
u.Path = "/" + strings.Join(parts[:len(parts)-2], "/")
u.RawPath, u.RawQuery, u.Fragment, u.User = "", "", "", nil
instance, err := NormalizeHost(u.String())
return instance, repo, err
}
// looksLikeWebResourcePath detects Gitea HTML routes such as owner/repo/issues/7, which have a
// web route keyword in a segment after the possible owner/repo. Taking the final two segments
// from those paths would invent the wrong repository.
func looksLikeWebResourcePath(parts []string) bool {
if len(parts) < 3 {
return false
}
for _, part := range parts[2:] {
switch strings.ToLower(part) {
case "issues", "pulls", "pull", "commit", "commits", "src", "tree", "blob", "raw",
"compare", "releases", "tags", "wiki", "actions", "settings", "projects",
"packages", "labels", "milestones", "activities", "forks", "watchers", "stars":
return true
}
}
return false
}
// ValidSlug reports whether slug is exactly owner/name.
func ValidSlug(slug string) bool {
pieces := strings.Split(slug, "/")
return len(pieces) == 2 && validPiece(pieces[0]) && validPiece(pieces[1])
}
// validPiece reports whether piece can be an owner or repository name.
func validPiece(piece string) bool {
// ':' keeps scp-style remotes such as git@host:owner/name.git from passing as a slug.
return piece != "" && piece != "." && piece != ".." && !strings.ContainsAny(piece, ":?#\\ \t\r\n")
}
// RepositoryRef is a parsed repository argument.
type RepositoryRef struct {
// Host is the instance URL the argument names, or "" when it names none.
Host string
// Owner is "" for a bare NAME, which belongs to the active account.
Owner, Name string
}
// Slug returns owner/name, or the bare name when the owner is not known yet.
func (r RepositoryRef) Slug() string {
if r.Owner == "" {
return r.Name
}
return r.Owner + "/" + r.Name
}
// ParseRepository reads a repository argument: NAME, OWNER/NAME, HOST/OWNER/NAME (with an
// optional .git suffix), or a clone or web URL. HOST may carry a port and a configured
// instance's base path; it takes the scheme of the configured instance it names, else https.
func ParseRepository(raw string, hosts map[string]Host) (RepositoryRef, error) {
raw = strings.TrimSpace(raw)
if strings.Contains(raw, "://") || scpLike(raw) {
host, slug, err := RepositoryURL(raw, hosts)
if err != nil {
return RepositoryRef{}, err
}
owner, name, _ := strings.Cut(slug, "/")
return RepositoryRef{Host: host, Owner: owner, Name: name}, nil
}
invalid := fmt.Errorf("invalid repository %q: use OWNER/REPO, HOST/OWNER/REPO or a URL", raw)
trimmed := strings.TrimSuffix(raw, ".git")
pieces := strings.Split(trimmed, "/")
switch {
case len(pieces) == 1 && validPiece(pieces[0]):
return RepositoryRef{Name: pieces[0]}, nil
case len(pieces) == 2 && ValidSlug(trimmed):
return RepositoryRef{Owner: pieces[0], Name: pieces[1]}, nil
case len(pieces) < 3:
return RepositoryRef{}, invalid
}
// The longest configured instance prefix wins, so example.org/gitea/owner/name selects the
// instance installed below /gitea rather than example.org.
best, tied := "", false
for instance := range hosts {
prefix := bareInstance(instance)
if !strings.HasPrefix(strings.ToLower(trimmed), strings.ToLower(prefix)+"/") || !ValidSlug(trimmed[len(prefix)+1:]) {
continue
}
switch longest := len(bareInstance(best)); {
case len(prefix) > longest:
best, tied = instance, false
case len(prefix) == longest && best != "":
tied = true // http and https of one host: the scheme decides, not map order.
}
}
if tied {
return RepositoryRef{}, fmt.Errorf("instance %q is configured for both http and https; include the scheme", bareInstance(best))
}
if best != "" {
owner, name, _ := strings.Cut(trimmed[len(bareInstance(best))+1:], "/")
return RepositoryRef{Host: best, Owner: owner, Name: name}, nil
}
if len(pieces) != 3 || !ValidSlug(pieces[1]+"/"+pieces[2]) {
return RepositoryRef{}, invalid
}
host, err := ResolveHost(pieces[0], hosts)
if err != nil {
return RepositoryRef{}, invalid
}
return RepositoryRef{Host: host, Owner: pieces[1], Name: pieces[2]}, nil
}
// ResolveHost normalizes an instance given as a URL or as a bare HOST[:PORT][/PATH]. A bare
// host takes the scheme of the configured instance it names, else https.
func ResolveHost(raw string, hosts map[string]Host) (string, error) {
raw = strings.TrimSpace(raw)
if strings.Contains(raw, "://") {
return NormalizeHost(raw)
}
bare := strings.TrimSuffix(raw, "/")
var matches []string
for instance := range hosts {
if strings.EqualFold(bareInstance(instance), bare) {
matches = append(matches, instance)
}
}
switch len(matches) {
case 0:
instance, err := NormalizeHost("https://" + raw)
if err != nil {
return "", fmt.Errorf("invalid instance %q: use HOST[:PORT][/PATH] or an http(s) URL", raw)
}
return instance, nil
case 1:
return matches[0], nil
}
return "", fmt.Errorf("instance %q is configured for both http and https; include the scheme", raw)
}
// bareInstance returns an instance URL without its scheme, such as localhost:3001/gitea.
func bareInstance(instance string) string {
_, bare, _ := strings.Cut(instance, "://")
return bare
}