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 }