|
| 1 | +// Copyright (c) HashiCorp, Inc. |
| 2 | +// SPDX-License-Identifier: MPL-2.0 |
| 3 | + |
| 4 | +package tfaddr |
| 5 | + |
| 6 | +import ( |
| 7 | + "fmt" |
| 8 | + "strings" |
| 9 | + |
| 10 | + svchost "github.com/hashicorp/terraform-svchost" |
| 11 | +) |
| 12 | + |
| 13 | +// Component represents a component listed in a Terraform component registry. |
| 14 | +// It combines a registry package address with an optional subdirectory path |
| 15 | +// to specify the exact location of a component within a registry package. |
| 16 | +// |
| 17 | +// Components are resolved through a two-step process: |
| 18 | +// 1. The registry package address is used to query the registry API |
| 19 | +// 2. The registry returns a ComponentSourceRemote with the actual source location |
| 20 | +// |
| 21 | +// Example component addresses: |
| 22 | +// - registry.terraform.io/hashicorp/aws |
| 23 | +// - registry.terraform.io/hashicorp/aws//modules/vpc |
| 24 | +// - example.com/myorg/mycomponent//subdir/component |
| 25 | +type Component struct { |
| 26 | + // Package is the registry package that the target component belongs to. |
| 27 | + // The component installer must translate this into a ComponentSourceRemote |
| 28 | + // using the registry API and then take that underlying address's |
| 29 | + // Package in order to find the actual package location. |
| 30 | + Package ComponentPackage |
| 31 | + |
| 32 | + // If Subdir is non-empty then it represents a sub-directory within the |
| 33 | + // remote package that the registry address eventually resolves to. |
| 34 | + // This will ultimately become the suffix of the Subdir of the |
| 35 | + // ComponentSourceRemote that the registry address translates to. |
| 36 | + // |
| 37 | + // Subdir uses a normalized forward-slash-based path syntax within the |
| 38 | + // virtual filesystem represented by the final package. It will never |
| 39 | + // include `../` or `./` sequences. |
| 40 | + Subdir string |
| 41 | +} |
| 42 | + |
| 43 | +// ParseComponentSource parses a string representation of a component registry |
| 44 | +// address and returns a Component struct. This function only accepts component |
| 45 | +// registry addresses and will reject any other address type. |
| 46 | +// |
| 47 | +// Supported address formats: |
| 48 | +// - "namespace/name" (uses default registry host) |
| 49 | +// - "hostname/namespace/name" (explicit registry host) |
| 50 | +// - "hostname/namespace/name//subdirectory" (with subdirectory) |
| 51 | +// |
| 52 | +// Examples: |
| 53 | +// - "hashicorp/aws" -> registry.terraform.io/hashicorp/aws |
| 54 | +// - "example.com/myorg/component" |
| 55 | +// - "registry.terraform.io/hashicorp/aws//modules/vpc" |
| 56 | +// |
| 57 | +// The function validates that: |
| 58 | +// - The address has 2 or 3 slash-separated segments |
| 59 | +// - Subdirectories don't escape the package with "../" paths |
| 60 | +// - The hostname (if provided) is not a reserved VCS host |
| 61 | +// - Namespace and component names follow registry naming conventions |
| 62 | +func ParseComponentSource(raw string) (Component, error) { |
| 63 | + var err error |
| 64 | + |
| 65 | + // Extract subdirectory path if present (indicated by "//" separator) |
| 66 | + var subDir string |
| 67 | + raw, subDir = splitPackageSubdir(raw) |
| 68 | + if strings.HasPrefix(subDir, "../") { |
| 69 | + return Component{}, fmt.Errorf("subdirectory path %q leads outside of the component package", subDir) |
| 70 | + } |
| 71 | + |
| 72 | + // Split the main address into its components |
| 73 | + parts := strings.Split(raw, "/") |
| 74 | + // A valid registry component address has either two or three parts, because the leading hostname part is optional. |
| 75 | + if len(parts) != 2 && len(parts) != 3 { |
| 76 | + return Component{}, fmt.Errorf("a component registry source address must have either two or three slash-separated segments") |
| 77 | + } |
| 78 | + |
| 79 | + // Default to the standard Terraform registry if no hostname is specified |
| 80 | + host := DefaultModuleRegistryHost |
| 81 | + // Check if hostname segment is present |
| 82 | + if len(parts) == 3 { |
| 83 | + host, err = svchost.ForComparison(parts[0]) |
| 84 | + if err != nil { |
| 85 | + // The svchost library doesn't produce very good error messages to |
| 86 | + // return to an end-user, so we'll use some custom ones here. |
| 87 | + switch { |
| 88 | + case strings.Contains(parts[0], "--"): |
| 89 | + // Looks like possibly punycode, which we don't allow here |
| 90 | + // to ensure that source addresses are written readably. |
| 91 | + return Component{}, fmt.Errorf("invalid component registry hostname %q; internationalized domain names must be given as direct unicode characters, not in punycode", parts[0]) |
| 92 | + default: |
| 93 | + return Component{}, fmt.Errorf("invalid component registry hostname %q", parts[0]) |
| 94 | + } |
| 95 | + } |
| 96 | + if !strings.Contains(host.String(), ".") { |
| 97 | + return Component{}, fmt.Errorf("invalid component registry hostname: must contain at least one dot") |
| 98 | + } |
| 99 | + // Discard the hostname prefix now that we've processed it |
| 100 | + parts = parts[1:] |
| 101 | + } |
| 102 | + |
| 103 | + ret := Component{ |
| 104 | + Package: ComponentPackage{ |
| 105 | + Host: host, |
| 106 | + }, |
| 107 | + Subdir: subDir, |
| 108 | + } |
| 109 | + |
| 110 | + // Prevent potential parsing collisions with known VCS hosts |
| 111 | + // These hostnames are reserved for direct VCS installation |
| 112 | + if host == svchost.Hostname("github.com") || host == svchost.Hostname("bitbucket.org") || host == svchost.Hostname("gitlab.com") { |
| 113 | + // NOTE: This may change in the future if we allow VCS installations |
| 114 | + // from these hosts to be registered in the component registry. |
| 115 | + // For now, we disallow them to avoid confusion. |
| 116 | + return ret, fmt.Errorf("can't use %q as a component registry host, because it's reserved for installing directly from version control repositories", host) |
| 117 | + } |
| 118 | + |
| 119 | + // Validate and assign the namespace segment |
| 120 | + if ret.Package.Namespace, err = parseModuleRegistryName(parts[0]); err != nil { |
| 121 | + if strings.Contains(parts[0], ".") { |
| 122 | + // Seems like the user omitted one of the latter components in |
| 123 | + // an address with an explicit hostname. |
| 124 | + return ret, fmt.Errorf("source address must have two more components after the hostname: the namespace and the name") |
| 125 | + } |
| 126 | + return ret, fmt.Errorf("invalid namespace %q: %s", parts[0], err) |
| 127 | + } |
| 128 | + |
| 129 | + // Validate and assign the component name segment |
| 130 | + if ret.Package.Name, err = parseModuleRegistryName(parts[1]); err != nil { |
| 131 | + if strings.Contains(parts[1], "?") { |
| 132 | + // The user was trying to include a query string, probably? |
| 133 | + return ret, fmt.Errorf("component registry addresses may not include a query string portion") |
| 134 | + } |
| 135 | + return ret, fmt.Errorf("invalid component name %q: %s", parts[1], err) |
| 136 | + } |
| 137 | + |
| 138 | + return ret, nil |
| 139 | +} |
| 140 | + |
| 141 | +// String returns a full representation of the component address, including any |
| 142 | +// additional components that are typically implied by omission in |
| 143 | +// user-written addresses. |
| 144 | +// |
| 145 | +// We typically use this longer representation in error messages, in case |
| 146 | +// the inclusion of normally-omitted components is helpful in debugging |
| 147 | +// unexpected behavior. |
| 148 | +func (c Component) String() string { |
| 149 | + if c.Subdir != "" { |
| 150 | + return c.Package.String() + "//" + c.Subdir |
| 151 | + } |
| 152 | + return c.Package.String() |
| 153 | +} |
| 154 | + |
| 155 | +// ForDisplay is similar to String but instead returns a representation of |
| 156 | +// the idiomatic way to write the address in configuration, omitting |
| 157 | +// components that are commonly just implied in addresses written by |
| 158 | +// users. |
| 159 | +// |
| 160 | +// We typically use this shorter representation in informational messages, |
| 161 | +// such as the note that we're about to start downloading a package. |
| 162 | +func (c Component) ForDisplay() string { |
| 163 | + if c.Subdir != "" { |
| 164 | + return c.Package.ForDisplay() + "//" + c.Subdir |
| 165 | + } |
| 166 | + return c.Package.ForDisplay() |
| 167 | +} |
0 commit comments