github.com/juju/charm/v11@v11.2.0/url.go (about)

     1  // Copyright 2011, 2012, 2013 Canonical Ltd.
     2  // Licensed under the LGPLv3, see LICENCE file for details.
     3  
     4  package charm
     5  
     6  import (
     7  	"encoding/json"
     8  	"fmt"
     9  	gourl "net/url"
    10  	"regexp"
    11  	"strconv"
    12  	"strings"
    13  
    14  	"github.com/juju/errors"
    15  	"github.com/juju/mgo/v3/bson"
    16  	"github.com/juju/utils/v3/arch"
    17  )
    18  
    19  // Schema represents the different types of valid schemas.
    20  type Schema string
    21  
    22  const (
    23  	// Local represents a local charm URL, describes as a file system path.
    24  	Local Schema = "local"
    25  
    26  	// CharmHub schema represents the charmhub charm repository.
    27  	CharmHub Schema = "ch"
    28  )
    29  
    30  // Prefix creates a url with the given prefix, useful for typed schemas.
    31  func (s Schema) Prefix(url string) string {
    32  	return fmt.Sprintf("%s:%s", s, url)
    33  }
    34  
    35  // Matches attempts to compare if a schema string matches the schema.
    36  func (s Schema) Matches(other string) bool {
    37  	return string(s) == other
    38  }
    39  
    40  func (s Schema) String() string {
    41  	return string(s)
    42  }
    43  
    44  // Location represents a charm location, which must declare a path component
    45  // and a string representation.
    46  type Location interface {
    47  	Path() string
    48  	String() string
    49  }
    50  
    51  // URL represents a charm or bundle location:
    52  //
    53  //	local:oneiric/wordpress
    54  //	ch:wordpress
    55  //	ch:amd64/jammy/wordpress-30
    56  type URL struct {
    57  	Schema       string // "ch" or "local".
    58  	Name         string // "wordpress".
    59  	Revision     int    // -1 if unset, N otherwise.
    60  	Series       string // "precise" or "" if unset; "bundle" if it's a bundle.
    61  	Architecture string // "amd64" or "" if unset for charmstore (v1) URLs.
    62  }
    63  
    64  var (
    65  	validArch   = regexp.MustCompile("^[a-z]+([a-z0-9]+)?$")
    66  	validSeries = regexp.MustCompile("^[a-z]+([a-z0-9]+)?$")
    67  	validName   = regexp.MustCompile("^[a-z][a-z0-9]*(-[a-z0-9]*[a-z][a-z0-9]*)*$")
    68  )
    69  
    70  // ValidateSchema returns an error if the schema is invalid.
    71  //
    72  // Valid schemas for the URL are:
    73  // - ch: charm hub
    74  // - local: local file
    75  
    76  func ValidateSchema(schema string) error {
    77  	switch schema {
    78  	case CharmHub.String(), Local.String():
    79  		return nil
    80  	}
    81  	return errors.NotValidf("schema %q", schema)
    82  }
    83  
    84  // IsValidSeries reports whether series is a valid series in charm or bundle
    85  // URLs.
    86  func IsValidSeries(series string) bool {
    87  	return validSeries.MatchString(series)
    88  }
    89  
    90  // ValidateSeries returns an error if the given series is invalid.
    91  func ValidateSeries(series string) error {
    92  	if IsValidSeries(series) {
    93  		return nil
    94  	}
    95  	return errors.NotValidf("series name %q", series)
    96  }
    97  
    98  // IsValidArchitecture reports whether the architecture is a valid architecture
    99  // in charm or bundle URLs.
   100  func IsValidArchitecture(architecture string) bool {
   101  	return validArch.MatchString(architecture) && arch.IsSupportedArch(architecture)
   102  }
   103  
   104  // ValidateArchitecture returns an error if the given architecture is invalid.
   105  func ValidateArchitecture(arch string) error {
   106  	if IsValidArchitecture(arch) {
   107  		return nil
   108  	}
   109  	return errors.NotValidf("architecture name %q", arch)
   110  }
   111  
   112  // IsValidName reports whether name is a valid charm or bundle name.
   113  func IsValidName(name string) bool {
   114  	return validName.MatchString(name)
   115  }
   116  
   117  // ValidateName returns an error if the given name is invalid.
   118  func ValidateName(name string) error {
   119  	if IsValidName(name) {
   120  		return nil
   121  	}
   122  	return errors.NotValidf("name %q", name)
   123  }
   124  
   125  // WithRevision returns a URL equivalent to url but with Revision set
   126  // to revision.
   127  func (u *URL) WithRevision(revision int) *URL {
   128  	urlCopy := *u
   129  	urlCopy.Revision = revision
   130  	return &urlCopy
   131  }
   132  
   133  // WithArchitecture returns a URL equivalent to url but with Architecture set
   134  // to architecture.
   135  func (u *URL) WithArchitecture(arch string) *URL {
   136  	urlCopy := *u
   137  	urlCopy.Architecture = arch
   138  	return &urlCopy
   139  }
   140  
   141  // WithSeries returns a URL equivalent to url but with Series set
   142  // to series.
   143  func (u *URL) WithSeries(series string) *URL {
   144  	urlCopy := *u
   145  	urlCopy.Series = series
   146  	return &urlCopy
   147  }
   148  
   149  // MustParseURL works like ParseURL, but panics in case of errors.
   150  func MustParseURL(url string) *URL {
   151  	u, err := ParseURL(url)
   152  	if err != nil {
   153  		panic(err)
   154  	}
   155  	return u
   156  }
   157  
   158  // ParseURL parses the provided charm URL string into its respective
   159  // structure.
   160  //
   161  // A missing schema is assumed to be 'ch'.
   162  func ParseURL(url string) (*URL, error) {
   163  	u, err := gourl.Parse(url)
   164  	if err != nil {
   165  		return nil, errors.Errorf("cannot parse charm or bundle URL: %q", url)
   166  	}
   167  	if u.RawQuery != "" || u.Fragment != "" || u.User != nil {
   168  		return nil, errors.Errorf("charm or bundle URL %q has unrecognized parts", url)
   169  	}
   170  	var curl *URL
   171  	switch {
   172  	case CharmHub.Matches(u.Scheme):
   173  		// Handle talking to the new style of the schema.
   174  		curl, err = parseCharmhubURL(u)
   175  	case u.Opaque != "":
   176  		u.Path = u.Opaque
   177  		curl, err = parseLocalURL(u, url)
   178  	default:
   179  		// Handle the fact that anything without a prefix is now a CharmHub
   180  		// charm URL.
   181  		curl, err = parseCharmhubURL(u)
   182  	}
   183  	if err != nil {
   184  		return nil, errors.Trace(err)
   185  	}
   186  	if curl.Schema == "" {
   187  		return nil, errors.Errorf("expected schema for charm or bundle URL: %q", url)
   188  	}
   189  	return curl, nil
   190  }
   191  
   192  func parseLocalURL(url *gourl.URL, originalURL string) (*URL, error) {
   193  	if !Local.Matches(url.Scheme) {
   194  		return nil, errors.NotValidf("cannot parse URL %q: schema %q", url, url.Scheme)
   195  	}
   196  	r := URL{Schema: Local.String()}
   197  
   198  	parts := strings.Split(url.Path[0:], "/")
   199  	if len(parts) < 1 || len(parts) > 4 {
   200  		return nil, errors.Errorf("charm or bundle URL has invalid form: %q", originalURL)
   201  	}
   202  
   203  	// ~<username>
   204  	if strings.HasPrefix(parts[0], "~") {
   205  		return nil, errors.Errorf("local charm or bundle URL with user name: %q", originalURL)
   206  	}
   207  
   208  	if len(parts) > 2 {
   209  		return nil, errors.Errorf("charm or bundle URL has invalid form: %q", originalURL)
   210  	}
   211  
   212  	// <series>
   213  	if len(parts) == 2 {
   214  		r.Series, parts = parts[0], parts[1:]
   215  		if err := ValidateSeries(r.Series); err != nil {
   216  			return nil, errors.Annotatef(err, "cannot parse URL %q", originalURL)
   217  		}
   218  	}
   219  	if len(parts) < 1 {
   220  		return nil, errors.Errorf("URL without charm or bundle name: %q", originalURL)
   221  	}
   222  
   223  	// <name>[-<revision>]
   224  	r.Name, r.Revision = extractRevision(parts[0])
   225  	if err := ValidateName(r.Name); err != nil {
   226  		return nil, errors.Annotatef(err, "cannot parse URL %q", url)
   227  	}
   228  	return &r, nil
   229  }
   230  
   231  func (u *URL) path() string {
   232  	var parts []string
   233  	if u.Architecture != "" {
   234  		parts = append(parts, u.Architecture)
   235  	}
   236  	if u.Series != "" {
   237  		parts = append(parts, u.Series)
   238  	}
   239  	if u.Revision >= 0 {
   240  		parts = append(parts, fmt.Sprintf("%s-%d", u.Name, u.Revision))
   241  	} else {
   242  		parts = append(parts, u.Name)
   243  	}
   244  	return strings.Join(parts, "/")
   245  }
   246  
   247  // FullPath returns the full path of a URL path including the schema.
   248  func (u *URL) FullPath() string {
   249  	return fmt.Sprintf("%s:%s", u.Schema, u.Path())
   250  }
   251  
   252  // Path returns the path of the URL without the schema.
   253  func (u *URL) Path() string {
   254  	return u.path()
   255  }
   256  
   257  // String returns the string representation of the URL.
   258  func (u *URL) String() string {
   259  	return u.FullPath()
   260  }
   261  
   262  // GetBSON turns u into a bson.Getter so it can be saved directly
   263  // on a MongoDB database with mgo.
   264  //
   265  // TODO (stickupkid): This should not be here, as this is purely for mongo
   266  // data stores and that should be implemented at the site of data store, not
   267  // dependant on the library.
   268  func (u *URL) GetBSON() (interface{}, error) {
   269  	if u == nil {
   270  		return nil, nil
   271  	}
   272  	return u.String(), nil
   273  }
   274  
   275  // SetBSON turns u into a bson.Setter so it can be loaded directly
   276  // from a MongoDB database with mgo.
   277  //
   278  // TODO (stickupkid): This should not be here, as this is purely for mongo
   279  // data stores and that should be implemented at the site of data store, not
   280  // dependant on the library.
   281  func (u *URL) SetBSON(raw bson.Raw) error {
   282  	if raw.Kind == 10 {
   283  		return bson.SetZero
   284  	}
   285  	var s string
   286  	err := raw.Unmarshal(&s)
   287  	if err != nil {
   288  		return err
   289  	}
   290  	url, err := ParseURL(s)
   291  	if err != nil {
   292  		return err
   293  	}
   294  	*u = *url
   295  	return nil
   296  }
   297  
   298  // MarshalJSON will marshal the URL into a slice of bytes in a JSON
   299  // representation.
   300  func (u *URL) MarshalJSON() ([]byte, error) {
   301  	if u == nil {
   302  		panic("cannot marshal nil *charm.URL")
   303  	}
   304  	return json.Marshal(u.FullPath())
   305  }
   306  
   307  // UnmarshalJSON will unmarshal the URL from a JSON representation.
   308  func (u *URL) UnmarshalJSON(b []byte) error {
   309  	var s string
   310  	if err := json.Unmarshal(b, &s); err != nil {
   311  		return err
   312  	}
   313  	url, err := ParseURL(s)
   314  	if err != nil {
   315  		return err
   316  	}
   317  	*u = *url
   318  	return nil
   319  }
   320  
   321  // MarshalText implements encoding.TextMarshaler by
   322  // returning u.FullPath()
   323  func (u *URL) MarshalText() ([]byte, error) {
   324  	if u == nil {
   325  		return nil, nil
   326  	}
   327  	return []byte(u.FullPath()), nil
   328  }
   329  
   330  // UnmarshalText implements encoding.TestUnmarshaler by
   331  // parsing the data with ParseURL.
   332  func (u *URL) UnmarshalText(data []byte) error {
   333  	url, err := ParseURL(string(data))
   334  	if err != nil {
   335  		return err
   336  	}
   337  	*u = *url
   338  	return nil
   339  }
   340  
   341  // Quote translates a charm url string into one which can be safely used
   342  // in a file path.  ASCII letters, ASCII digits, dot and dash stay the
   343  // same; other characters are translated to their hex representation
   344  // surrounded by underscores.
   345  func Quote(unsafe string) string {
   346  	safe := make([]byte, 0, len(unsafe)*4)
   347  	for i := 0; i < len(unsafe); i++ {
   348  		b := unsafe[i]
   349  		switch {
   350  		case b >= 'a' && b <= 'z',
   351  			b >= 'A' && b <= 'Z',
   352  			b >= '0' && b <= '9',
   353  			b == '.',
   354  			b == '-':
   355  			safe = append(safe, b)
   356  		default:
   357  			safe = append(safe, fmt.Sprintf("_%02x_", b)...)
   358  		}
   359  	}
   360  	return string(safe)
   361  }
   362  
   363  // parseCharmhubURL will attempt to parse an identifier URL. The identifier
   364  // URL is split up into 3 parts, some of which are optional and some are
   365  // mandatory.
   366  //
   367  //   - architecture (optional)
   368  //   - series (optional)
   369  //   - name
   370  //   - revision (optional)
   371  //
   372  // Examples are as follows:
   373  //
   374  //   - ch:amd64/foo-1
   375  //   - ch:amd64/focal/foo-1
   376  //   - ch:foo-1
   377  //   - ch:foo
   378  //   - ch:amd64/focal/foo
   379  func parseCharmhubURL(url *gourl.URL) (*URL, error) {
   380  	r := URL{
   381  		Schema:   CharmHub.String(),
   382  		Revision: -1,
   383  	}
   384  
   385  	path := url.Path
   386  	if url.Opaque != "" {
   387  		path = url.Opaque
   388  	}
   389  
   390  	parts := strings.Split(strings.Trim(path, "/"), "/")
   391  	if len(parts) == 0 || len(parts) > 3 {
   392  		return nil, errors.Errorf(`charm or bundle URL %q malformed`, url)
   393  	}
   394  
   395  	// ~<username>
   396  	if strings.HasPrefix(parts[0], "~") {
   397  		return nil, errors.NotValidf("charmhub charm or bundle URL with user name: %q", url)
   398  	}
   399  
   400  	var nameRev string
   401  	switch len(parts) {
   402  	case 3:
   403  		r.Architecture, r.Series, nameRev = parts[0], parts[1], parts[2]
   404  
   405  		if err := ValidateArchitecture(r.Architecture); err != nil {
   406  			return nil, errors.Annotatef(err, "in URL %q", url)
   407  		}
   408  	case 2:
   409  		// Since both the architecture and series are optional,
   410  		// the first part can be either architecture or series.
   411  		// To differentiate between them, we go ahead and try to
   412  		// validate the first part as an architecture to decide.
   413  
   414  		if err := ValidateArchitecture(parts[0]); err == nil {
   415  			r.Architecture, nameRev = parts[0], parts[1]
   416  		} else {
   417  			r.Series, nameRev = parts[0], parts[1]
   418  		}
   419  
   420  	default:
   421  		nameRev = parts[0]
   422  	}
   423  
   424  	// Mandatory
   425  	r.Name, r.Revision = extractRevision(nameRev)
   426  	if err := ValidateName(r.Name); err != nil {
   427  		return nil, errors.Annotatef(err, "cannot parse name and/or revision in URL %q", url)
   428  	}
   429  
   430  	// Optional
   431  	if r.Series != "" {
   432  		if err := ValidateSeries(r.Series); err != nil {
   433  			return nil, errors.Annotatef(err, "in URL %q", url)
   434  		}
   435  	}
   436  
   437  	return &r, nil
   438  }
   439  
   440  // EnsureSchema will ensure that the scheme for a given URL is correct and
   441  // valid. If the url does not specify a schema, the provided defaultSchema
   442  // will be injected to it.
   443  func EnsureSchema(url string, defaultSchema Schema) (string, error) {
   444  	u, err := gourl.Parse(url)
   445  	if err != nil {
   446  		return "", errors.Errorf("cannot parse charm or bundle URL: %q", url)
   447  	}
   448  	switch Schema(u.Scheme) {
   449  	case CharmHub, Local:
   450  		return url, nil
   451  	case Schema(""):
   452  		// If the schema is empty, we fall back to the default schema.
   453  		return defaultSchema.Prefix(url), nil
   454  	default:
   455  		return "", errors.NotValidf("schema %q", u.Scheme)
   456  	}
   457  }
   458  
   459  func extractRevision(name string) (string, int) {
   460  	revision := -1
   461  	for i := len(name) - 1; i > 0; i-- {
   462  		c := name[i]
   463  		if c >= '0' && c <= '9' {
   464  			continue
   465  		}
   466  		if c == '-' && i != len(name)-1 {
   467  			var err error
   468  			revision, err = strconv.Atoi(name[i+1:])
   469  			if err != nil {
   470  				panic(err) // We just checked it was right.
   471  			}
   472  			name = name[:i]
   473  		}
   474  		break
   475  	}
   476  	return name, revision
   477  }