internal/remoteurl/remoteurl.go3.3 KB · Go
Raw
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
package remoteurl

import (
	"fmt"
	"net/url"
	"path"
	"strings"
)

// Spec describes the authenticated S3 location used for pushes and the
// optional public HTTP location used for clones.
type Spec struct {
	Bucket    string
	Prefix    string
	Endpoint  string
	Profile   string
	PublicURL string
}

func Parse(raw string) (Spec, error) {
	u, err := url.Parse(raw)
	if err != nil {
		return Spec{}, fmt.Errorf("parse remote URL: %w", err)
	}
	if u.Scheme != "dumbforge" {
		return Spec{}, fmt.Errorf("unsupported remote scheme %q", u.Scheme)
	}
	if u.Fragment != "" {
		return Spec{}, fmt.Errorf("remote URL must not contain a fragment")
	}
	if u.User != nil {
		if _, hasPassword := u.User.Password(); hasPassword {
			return Spec{}, fmt.Errorf("credentials must not be embedded in the remote URL")
		}
	}

	spec := Spec{
		Bucket:    u.Hostname(),
		Prefix:    strings.Trim(u.Path, "/"),
		Endpoint:  strings.TrimRight(u.Query().Get("endpoint"), "/"),
		PublicURL: strings.TrimRight(u.Query().Get("public"), "/"),
	}
	if u.User != nil {
		spec.Profile = u.User.Username()
	}
	if err := validate(spec); err != nil {
		return Spec{}, err
	}
	return spec, nil
}

func Build(spec Spec) (string, error) {
	spec.Prefix = strings.Trim(spec.Prefix, "/")
	if err := validate(spec); err != nil {
		return "", err
	}
	u := &url.URL{
		Scheme: "dumbforge",
		Host:   spec.Bucket,
		Path:   "/" + strings.Trim(spec.Prefix, "/"),
	}
	if spec.Profile != "" {
		u.User = url.User(spec.Profile)
	}
	q := url.Values{}
	q.Set("endpoint", strings.TrimRight(spec.Endpoint, "/"))
	if spec.PublicURL != "" {
		q.Set("public", strings.TrimRight(spec.PublicURL, "/"))
	}
	u.RawQuery = q.Encode()
	return u.String(), nil
}

func (s Spec) PublicRepoURL() string {
	if s.PublicURL == "" {
		return ""
	}
	base, err := url.Parse(s.PublicURL)
	if err != nil {
		return ""
	}
	base.Path = strings.TrimRight(base.Path, "/") + "/" + path.Clean(strings.Trim(s.Prefix, "/"))
	base.RawPath = ""
	return base.String()
}

func validate(spec Spec) error {
	if spec.Bucket == "" {
		return fmt.Errorf("bucket is required")
	}
	if strings.ContainsAny(spec.Bucket, "/\\\x00\r\n\t ") {
		return fmt.Errorf("bucket contains invalid characters")
	}
	if spec.Prefix == "" {
		return fmt.Errorf("repository prefix is required")
	}
	unsafeSegment := false
	for _, segment := range strings.Split(spec.Prefix, "/") {
		if segment == "." || segment == ".." {
			unsafeSegment = true
			break
		}
	}
	if strings.ContainsAny(spec.Prefix, "\\\x00\r\n") || path.Clean(spec.Prefix) != spec.Prefix || unsafeSegment {
		return fmt.Errorf("repository prefix must be a canonical relative object path")
	}
	if err := validateHTTPURL("S3 endpoint", spec.Endpoint, true); err != nil {
		return err
	}
	if err := validateHTTPURL("public URL", spec.PublicURL, false); err != nil {
		return err
	}
	return nil
}

func validateHTTPURL(label, value string, required bool) error {
	if value == "" {
		if required {
			return fmt.Errorf("%s is required", label)
		}
		return nil
	}
	parsed, err := url.Parse(value)
	if err != nil || (parsed.Scheme != "https" && parsed.Scheme != "http") || parsed.Host == "" {
		return fmt.Errorf("%s must be an absolute HTTP or HTTPS URL", label)
	}
	if parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" {
		return fmt.Errorf("%s must not contain credentials, a query, or a fragment", label)
	}
	return nil
}