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 }