1package addrs
2
3import (
4	"fmt"
5	"strings"
6
7	"golang.org/x/net/idna"
8
9	"github.com/hashicorp/hcl/v2"
10	svchost "github.com/hashicorp/terraform-svchost"
11	"github.com/hashicorp/terraform/internal/tfdiags"
12)
13
14// Provider encapsulates a single provider type. In the future this will be
15// extended to include additional fields including Namespace and SourceHost
16type Provider struct {
17	Type      string
18	Namespace string
19	Hostname  svchost.Hostname
20}
21
22// DefaultRegistryHost is the hostname used for provider addresses that do
23// not have an explicit hostname.
24const DefaultRegistryHost = svchost.Hostname("registry.terraform.io")
25
26// BuiltInProviderHost is the pseudo-hostname used for the "built-in" provider
27// namespace. Built-in provider addresses must also have their namespace set
28// to BuiltInProviderNamespace in order to be considered as built-in.
29const BuiltInProviderHost = svchost.Hostname("terraform.io")
30
31// BuiltInProviderNamespace is the provider namespace used for "built-in"
32// providers. Built-in provider addresses must also have their hostname
33// set to BuiltInProviderHost in order to be considered as built-in.
34//
35// The this namespace is literally named "builtin", in the hope that users
36// who see FQNs containing this will be able to infer the way in which they are
37// special, even if they haven't encountered the concept formally yet.
38const BuiltInProviderNamespace = "builtin"
39
40// LegacyProviderNamespace is the special string used in the Namespace field
41// of type Provider to mark a legacy provider address. This special namespace
42// value would normally be invalid, and can be used only when the hostname is
43// DefaultRegistryHost because that host owns the mapping from legacy name to
44// FQN.
45const LegacyProviderNamespace = "-"
46
47// String returns an FQN string, indended for use in machine-readable output.
48func (pt Provider) String() string {
49	if pt.IsZero() {
50		panic("called String on zero-value addrs.Provider")
51	}
52	return pt.Hostname.ForDisplay() + "/" + pt.Namespace + "/" + pt.Type
53}
54
55// ForDisplay returns a user-friendly FQN string, simplified for readability. If
56// the provider is using the default hostname, the hostname is omitted.
57func (pt Provider) ForDisplay() string {
58	if pt.IsZero() {
59		panic("called ForDisplay on zero-value addrs.Provider")
60	}
61
62	if pt.Hostname == DefaultRegistryHost {
63		return pt.Namespace + "/" + pt.Type
64	}
65	return pt.Hostname.ForDisplay() + "/" + pt.Namespace + "/" + pt.Type
66}
67
68// NewProvider constructs a provider address from its parts, and normalizes
69// the namespace and type parts to lowercase using unicode case folding rules
70// so that resulting addrs.Provider values can be compared using standard
71// Go equality rules (==).
72//
73// The hostname is given as a svchost.Hostname, which is required by the
74// contract of that type to have already been normalized for equality testing.
75//
76// This function will panic if the given namespace or type name are not valid.
77// When accepting namespace or type values from outside the program, use
78// ParseProviderPart first to check that the given value is valid.
79func NewProvider(hostname svchost.Hostname, namespace, typeName string) Provider {
80	if namespace == LegacyProviderNamespace {
81		// Legacy provider addresses must always be created via
82		// NewLegacyProvider so that we can use static analysis to find
83		// codepaths still working with those.
84		panic("attempt to create legacy provider address using NewProvider; use NewLegacyProvider instead")
85	}
86
87	return Provider{
88		Type:      MustParseProviderPart(typeName),
89		Namespace: MustParseProviderPart(namespace),
90		Hostname:  hostname,
91	}
92}
93
94// ImpliedProviderForUnqualifiedType represents the rules for inferring what
95// provider FQN a user intended when only a naked type name is available.
96//
97// For all except the type name "terraform" this returns a so-called "default"
98// provider, which is under the registry.terraform.io/hashicorp/ namespace.
99//
100// As a special case, the string "terraform" maps to
101// "terraform.io/builtin/terraform" because that is the more likely user
102// intent than the now-unmaintained "registry.terraform.io/hashicorp/terraform"
103// which remains only for compatibility with older Terraform versions.
104func ImpliedProviderForUnqualifiedType(typeName string) Provider {
105	switch typeName {
106	case "terraform":
107		// Note for future maintainers: any additional strings we add here
108		// as implied to be builtin must never also be use as provider names
109		// in the registry.terraform.io/hashicorp/... namespace, because
110		// otherwise older versions of Terraform could implicitly select
111		// the registry name instead of the internal one.
112		return NewBuiltInProvider(typeName)
113	default:
114		return NewDefaultProvider(typeName)
115	}
116}
117
118// NewDefaultProvider returns the default address of a HashiCorp-maintained,
119// Registry-hosted provider.
120func NewDefaultProvider(name string) Provider {
121	return Provider{
122		Type:      MustParseProviderPart(name),
123		Namespace: "hashicorp",
124		Hostname:  DefaultRegistryHost,
125	}
126}
127
128// NewBuiltInProvider returns the address of a "built-in" provider. See
129// the docs for Provider.IsBuiltIn for more information.
130func NewBuiltInProvider(name string) Provider {
131	return Provider{
132		Type:      MustParseProviderPart(name),
133		Namespace: BuiltInProviderNamespace,
134		Hostname:  BuiltInProviderHost,
135	}
136}
137
138// NewLegacyProvider returns a mock address for a provider.
139// This will be removed when ProviderType is fully integrated.
140func NewLegacyProvider(name string) Provider {
141	return Provider{
142		// We intentionally don't normalize and validate the legacy names,
143		// because existing code expects legacy provider names to pass through
144		// verbatim, even if not compliant with our new naming rules.
145		Type:      name,
146		Namespace: LegacyProviderNamespace,
147		Hostname:  DefaultRegistryHost,
148	}
149}
150
151// LegacyString returns the provider type, which is frequently used
152// interchangeably with provider name. This function can and should be removed
153// when provider type is fully integrated. As a safeguard for future
154// refactoring, this function panics if the Provider is not a legacy provider.
155func (pt Provider) LegacyString() string {
156	if pt.IsZero() {
157		panic("called LegacyString on zero-value addrs.Provider")
158	}
159	if pt.Namespace != LegacyProviderNamespace && pt.Namespace != BuiltInProviderNamespace {
160		panic(pt.String() + " cannot be represented as a legacy string")
161	}
162	return pt.Type
163}
164
165// IsZero returns true if the receiver is the zero value of addrs.Provider.
166//
167// The zero value is not a valid addrs.Provider and calling other methods on
168// such a value is likely to either panic or otherwise misbehave.
169func (pt Provider) IsZero() bool {
170	return pt == Provider{}
171}
172
173// IsBuiltIn returns true if the receiver is the address of a "built-in"
174// provider. That is, a provider under terraform.io/builtin/ which is
175// included as part of the Terraform binary itself rather than one to be
176// installed from elsewhere.
177//
178// These are ignored by the provider installer because they are assumed to
179// already be available without any further installation.
180func (pt Provider) IsBuiltIn() bool {
181	return pt.Hostname == BuiltInProviderHost && pt.Namespace == BuiltInProviderNamespace
182}
183
184// LessThan returns true if the receiver should sort before the other given
185// address in an ordered list of provider addresses.
186//
187// This ordering is an arbitrary one just to allow deterministic results from
188// functions that would otherwise have no natural ordering. It's subject
189// to change in future.
190func (pt Provider) LessThan(other Provider) bool {
191	switch {
192	case pt.Hostname != other.Hostname:
193		return pt.Hostname < other.Hostname
194	case pt.Namespace != other.Namespace:
195		return pt.Namespace < other.Namespace
196	default:
197		return pt.Type < other.Type
198	}
199}
200
201// IsLegacy returns true if the provider is a legacy-style provider
202func (pt Provider) IsLegacy() bool {
203	if pt.IsZero() {
204		panic("called IsLegacy() on zero-value addrs.Provider")
205	}
206
207	return pt.Hostname == DefaultRegistryHost && pt.Namespace == LegacyProviderNamespace
208
209}
210
211// IsDefault returns true if the provider is a default hashicorp provider
212func (pt Provider) IsDefault() bool {
213	if pt.IsZero() {
214		panic("called IsDefault() on zero-value addrs.Provider")
215	}
216
217	return pt.Hostname == DefaultRegistryHost && pt.Namespace == "hashicorp"
218}
219
220// Equals returns true if the receiver and other provider have the same attributes.
221func (pt Provider) Equals(other Provider) bool {
222	return pt == other
223}
224
225// ParseProviderSourceString parses the source attribute and returns a provider.
226// This is intended primarily to parse the FQN-like strings returned by
227// terraform-config-inspect.
228//
229// The following are valid source string formats:
230// 		name
231// 		namespace/name
232// 		hostname/namespace/name
233func ParseProviderSourceString(str string) (Provider, tfdiags.Diagnostics) {
234	var ret Provider
235	var diags tfdiags.Diagnostics
236
237	// split the source string into individual components
238	parts := strings.Split(str, "/")
239	if len(parts) == 0 || len(parts) > 3 {
240		diags = diags.Append(&hcl.Diagnostic{
241			Severity: hcl.DiagError,
242			Summary:  "Invalid provider source string",
243			Detail:   `The "source" attribute must be in the format "[hostname/][namespace/]name"`,
244		})
245		return ret, diags
246	}
247
248	// check for an invalid empty string in any part
249	for i := range parts {
250		if parts[i] == "" {
251			diags = diags.Append(&hcl.Diagnostic{
252				Severity: hcl.DiagError,
253				Summary:  "Invalid provider source string",
254				Detail:   `The "source" attribute must be in the format "[hostname/][namespace/]name"`,
255			})
256			return ret, diags
257		}
258	}
259
260	// check the 'name' portion, which is always the last part
261	givenName := parts[len(parts)-1]
262	name, err := ParseProviderPart(givenName)
263	if err != nil {
264		diags = diags.Append(&hcl.Diagnostic{
265			Severity: hcl.DiagError,
266			Summary:  "Invalid provider type",
267			Detail:   fmt.Sprintf(`Invalid provider type %q in source %q: %s"`, givenName, str, err),
268		})
269		return ret, diags
270	}
271	ret.Type = name
272	ret.Hostname = DefaultRegistryHost
273
274	if len(parts) == 1 {
275		return NewDefaultProvider(parts[0]), diags
276	}
277
278	if len(parts) >= 2 {
279		// the namespace is always the second-to-last part
280		givenNamespace := parts[len(parts)-2]
281		if givenNamespace == LegacyProviderNamespace {
282			// For now we're tolerating legacy provider addresses until we've
283			// finished updating the rest of the codebase to no longer use them,
284			// or else we'd get errors round-tripping through legacy subsystems.
285			ret.Namespace = LegacyProviderNamespace
286		} else {
287			namespace, err := ParseProviderPart(givenNamespace)
288			if err != nil {
289				diags = diags.Append(&hcl.Diagnostic{
290					Severity: hcl.DiagError,
291					Summary:  "Invalid provider namespace",
292					Detail:   fmt.Sprintf(`Invalid provider namespace %q in source %q: %s"`, namespace, str, err),
293				})
294				return Provider{}, diags
295			}
296			ret.Namespace = namespace
297		}
298	}
299
300	// Final Case: 3 parts
301	if len(parts) == 3 {
302		// the namespace is always the first part in a three-part source string
303		hn, err := svchost.ForComparison(parts[0])
304		if err != nil {
305			diags = diags.Append(&hcl.Diagnostic{
306				Severity: hcl.DiagError,
307				Summary:  "Invalid provider source hostname",
308				Detail:   fmt.Sprintf(`Invalid provider source hostname namespace %q in source %q: %s"`, hn, str, err),
309			})
310			return Provider{}, diags
311		}
312		ret.Hostname = hn
313	}
314
315	if ret.Namespace == LegacyProviderNamespace && ret.Hostname != DefaultRegistryHost {
316		// Legacy provider addresses must always be on the default registry
317		// host, because the default registry host decides what actual FQN
318		// each one maps to.
319		diags = diags.Append(&hcl.Diagnostic{
320			Severity: hcl.DiagError,
321			Summary:  "Invalid provider namespace",
322			Detail:   "The legacy provider namespace \"-\" can be used only with hostname " + DefaultRegistryHost.ForDisplay() + ".",
323		})
324		return Provider{}, diags
325	}
326
327	// Due to how plugin executables are named and provider git repositories
328	// are conventionally named, it's a reasonable and
329	// apparently-somewhat-common user error to incorrectly use the
330	// "terraform-provider-" prefix in a provider source address. There is
331	// no good reason for a provider to have the prefix "terraform-" anyway,
332	// so we've made that invalid from the start both so we can give feedback
333	// to provider developers about the terraform- prefix being redundant
334	// and give specialized feedback to folks who incorrectly use the full
335	// terraform-provider- prefix to help them self-correct.
336	const redundantPrefix = "terraform-"
337	const userErrorPrefix = "terraform-provider-"
338	if strings.HasPrefix(ret.Type, redundantPrefix) {
339		if strings.HasPrefix(ret.Type, userErrorPrefix) {
340			// Likely user error. We only return this specialized error if
341			// whatever is after the prefix would otherwise be a
342			// syntactically-valid provider type, so we don't end up advising
343			// the user to try something that would be invalid for another
344			// reason anyway.
345			// (This is mainly just for robustness, because the validation
346			// we already did above should've rejected most/all ways for
347			// the suggestedType to end up invalid here.)
348			suggestedType := ret.Type[len(userErrorPrefix):]
349			if _, err := ParseProviderPart(suggestedType); err == nil {
350				suggestedAddr := ret
351				suggestedAddr.Type = suggestedType
352				diags = diags.Append(tfdiags.Sourceless(
353					tfdiags.Error,
354					"Invalid provider type",
355					fmt.Sprintf("Provider source %q has a type with the prefix %q, which isn't valid. Although that prefix is often used in the names of version control repositories for Terraform providers, provider source strings should not include it.\n\nDid you mean %q?", ret.ForDisplay(), userErrorPrefix, suggestedAddr.ForDisplay()),
356				))
357				return Provider{}, diags
358			}
359		}
360		// Otherwise, probably instead an incorrectly-named provider, perhaps
361		// arising from a similar instinct to what causes there to be
362		// thousands of Python packages on PyPI with "python-"-prefixed
363		// names.
364		diags = diags.Append(tfdiags.Sourceless(
365			tfdiags.Error,
366			"Invalid provider type",
367			fmt.Sprintf("Provider source %q has a type with the prefix %q, which isn't allowed because it would be redundant to name a Terraform provider with that prefix. If you are the author of this provider, rename it to not include the prefix.", ret, redundantPrefix),
368		))
369		return Provider{}, diags
370	}
371
372	return ret, diags
373}
374
375// MustParseProviderSourceString is a wrapper around ParseProviderSourceString that panics if
376// it returns an error.
377func MustParseProviderSourceString(str string) Provider {
378	result, diags := ParseProviderSourceString(str)
379	if diags.HasErrors() {
380		panic(diags.Err().Error())
381	}
382	return result
383}
384
385// ParseProviderPart processes an addrs.Provider namespace or type string
386// provided by an end-user, producing a normalized version if possible or
387// an error if the string contains invalid characters.
388//
389// A provider part is processed in the same way as an individual label in a DNS
390// domain name: it is transformed to lowercase per the usual DNS case mapping
391// and normalization rules and may contain only letters, digits, and dashes.
392// Additionally, dashes may not appear at the start or end of the string.
393//
394// These restrictions are intended to allow these names to appear in fussy
395// contexts such as directory/file names on case-insensitive filesystems,
396// repository names on GitHub, etc. We're using the DNS rules in particular,
397// rather than some similar rules defined locally, because the hostname part
398// of an addrs.Provider is already a hostname and it's ideal to use exactly
399// the same case folding and normalization rules for all of the parts.
400//
401// In practice a provider type string conventionally does not contain dashes
402// either. Such names are permitted, but providers with such type names will be
403// hard to use because their resource type names will not be able to contain
404// the provider type name and thus each resource will need an explicit provider
405// address specified. (A real-world example of such a provider is the
406// "google-beta" variant of the GCP provider, which has resource types that
407// start with the "google_" prefix instead.)
408//
409// It's valid to pass the result of this function as the argument to a
410// subsequent call, in which case the result will be identical.
411func ParseProviderPart(given string) (string, error) {
412	if len(given) == 0 {
413		return "", fmt.Errorf("must have at least one character")
414	}
415
416	// We're going to process the given name using the same "IDNA" library we
417	// use for the hostname portion, since it already implements the case
418	// folding rules we want.
419	//
420	// The idna library doesn't expose individual label parsing directly, but
421	// once we've verified it doesn't contain any dots we can just treat it
422	// like a top-level domain for this library's purposes.
423	if strings.ContainsRune(given, '.') {
424		return "", fmt.Errorf("dots are not allowed")
425	}
426
427	// We don't allow names containing multiple consecutive dashes, just as
428	// a matter of preference: they look weird, confusing, or incorrect.
429	// This also, as a side-effect, prevents the use of the "punycode"
430	// indicator prefix "xn--" that would cause the IDNA library to interpret
431	// the given name as punycode, because that would be weird and unexpected.
432	if strings.Contains(given, "--") {
433		return "", fmt.Errorf("cannot use multiple consecutive dashes")
434	}
435
436	result, err := idna.Lookup.ToUnicode(given)
437	if err != nil {
438		return "", fmt.Errorf("must contain only letters, digits, and dashes, and may not use leading or trailing dashes")
439	}
440
441	return result, nil
442}
443
444// MustParseProviderPart is a wrapper around ParseProviderPart that panics if
445// it returns an error.
446func MustParseProviderPart(given string) string {
447	result, err := ParseProviderPart(given)
448	if err != nil {
449		panic(err.Error())
450	}
451	return result
452}
453
454// IsProviderPartNormalized compares a given string to the result of ParseProviderPart(string)
455func IsProviderPartNormalized(str string) (bool, error) {
456	normalized, err := ParseProviderPart(str)
457	if err != nil {
458		return false, err
459	}
460	if str == normalized {
461		return true, nil
462	}
463	return false, nil
464}
465